<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Delta1 Labs]]></title><description><![CDATA[Delta1 Labs]]></description><link>https://delta1labs.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 08:31:27 GMT</lastBuildDate><atom:link href="https://delta1labs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Verifying your obfuscation in CI — the protection report and `nebula verify`]]></title><description><![CDATA[Your release pipeline is green. The obfuscation step ran, printed no errors, and the signed package is sitting in your artifact store. So the code is…]]></description><link>https://delta1labs.hashnode.dev/verifying-your-obfuscation-in-ci-the-protection-report-and-nebula-verify</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/verifying-your-obfuscation-in-ci-the-protection-report-and-nebula-verify</guid><category><![CDATA[obfuscation]]></category><category><![CDATA[nebulanet]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Thu, 12 Nov 2026 05:46:58 GMT</pubDate><content:encoded><![CDATA[<p>Your release pipeline is green. The obfuscation step ran, printed no errors, and the signed package is sitting in your artifact store. So the code is protected — right?</p>
<p>A successful build only proves the obfuscator did not crash. It says nothing about whether the output is actually hardened. A keep rule that is one glob too wide, a feature that is not enabled on your edition, a config key you misspelled, or an input that was already processed will all produce a clean exit code and an assembly that is wide open. This is the <strong>silent fallback to no protection</strong>, and because nothing fails, it survives all the way to production.</p>
<p>Nebula.NET 2.2.0 closes that gap with two things that belong in every pipeline: a <strong>protection report</strong> emitted on every build, and <strong><code>nebula verify</code></strong>, a gate that fails the build when the transforms you asked for did not land.</p>
<h2>The protection report</h2>
<p>Every Nebula.NET build can emit a machine-readable summary of what it actually did. It is plain JSON with a <strong>versioned, stable schema</strong>, so you can parse it, archive it, and diff it across builds without worrying that the next release will reshuffle the fields.</p>
<p>Turn it on in your <code>nebula.json</code>:</p>
<pre><code class="language-json">{
  "obfuscate": true,
  "rename": true,
  "stringEncryption": true,
  "controlFlow": true,
  "report": {
    "enabled": true,
    "path": "artifacts/nebula-report.json"
  }
}
</code></pre>
<p>Or ask for it on the command line, which overrides the config and is handy in CI:</p>
<pre><code class="language-bash">nebula protect MyApp.dll --report artifacts/nebula-report.json
</code></pre>
<p>Here is a real excerpt from a report for a Pro build:</p>
<pre><code class="language-json">{
  "schemaVersion": "1.0",
  "tool": { "name": "Nebula.NET", "version": "2.2.0", "edition": "Pro" },
  "input":  { "assembly": "MyApp.dll", "mvid": "2f1c8e0a-7b44-4d2e-9a6b-1e5d0c3f9a21" },
  "output": { "assembly": "MyApp.dll", "mvid": "b9d4e7c2-0a31-4f88-ae10-6c2f7b4d1e90" },
  "summary": {
    "typesTotal": 412,
    "methodsTotal": 3187,
    "renamedMembers": 3904,
    "renameRatio": 0.97,
    "residualPublicTypes": 12
  },
  "transforms": [
    { "name": "rename",           "requested": true, "applied": true, "typesAffected": 400, "methodsAffected": 3090 },
    { "name": "stringEncryption", "requested": true, "applied": true, "stringsEncrypted": 1884 },
    { "name": "controlFlow",      "requested": true, "applied": true, "methodsAffected": 2731 },
    { "name": "virtualization",   "requested": false, "applied": false }
  ],
  "residualPublicSurface": [
    "MyApp.PluginContract",
    "MyApp.PluginContract.IPlugin",
    "MyApp.Api.HealthController"
  ]
}
</code></pre>
<h3>How to read it</h3>
<p>A handful of fields tell you almost everything:</p>
<ul>
<li><strong><code>transforms[].requested</code> vs <code>applied</code></strong> — the single most important comparison. If you asked for <code>controlFlow</code> and <code>applied</code> is <code>false</code>, your flattening did not happen. This is the field <code>nebula verify</code> keys on.</li>
<li><strong><code>summary.renameRatio</code></strong> — the fraction of eligible members that were renamed. A healthy library build sits in the high 0.9s. If it drops to, say, 0.4, a keep rule is excluding far more than you intended.</li>
<li><strong><code>summary.residualPublicTypes</code> / <code>residualPublicSurface</code></strong> — the public API you deliberately left legible, because callers bind to it. This should be a short, familiar list: your plugin contracts, your public entry points. If a type you expected to be hidden shows up here, it is reachable through your public surface and was skipped on purpose — a signal to tighten your keep rules.</li>
<li><strong><code>output.mvid</code></strong> — the Module Version ID of the produced assembly. Because it changes on every real rewrite, it is a cheap tamper and freshness check: if <code>input.mvid</code> equals <code>output.mvid</code>, nothing was rewritten at all.</li>
</ul>
<p>On <strong>Pro and Enterprise</strong>, each transform also carries a per-type breakdown (which types were flattened, which methods were virtualized), so you can confirm your most sensitive methods specifically were covered rather than trusting an aggregate count. All editions emit the summary and the top-level transform results.</p>
<h2><code>nebula verify</code> — the CI gate</h2>
<p>Reading the report by eye is fine once. In a pipeline you want a machine to do it on every build and stop the line when something is wrong. That is <code>nebula verify</code>.</p>
<p>Point it at the output assembly (or the report directly). It compares requested transforms against applied ones and <strong>exits non-zero if any requested transform did not land</strong>:</p>
<pre><code class="language-bash">nebula verify MyApp.dll --report artifacts/nebula-report.json
</code></pre>
<p>The exit codes are stable and scriptable:</p>
<table>
<thead>
<tr>
<th>Exit code</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>0</code></td>
<td>Every requested transform was applied. Pass.</td>
</tr>
<tr>
<td><code>3</code></td>
<td>At least one requested transform did not land. <strong>Gate fail.</strong></td>
</tr>
<tr>
<td><code>1</code></td>
<td>Usage or I/O error (missing file, unreadable report).</td>
</tr>
</tbody></table>
<p>Exit code <code>3</code> is dedicated to the one failure you actually care about — "you asked for protection and did not get it" — so you can distinguish a real protection regression from a broken invocation.</p>
<p>You can also assert specific transforms regardless of what the config requested, which is useful when a build profile <em>must</em> include a given layer:</p>
<pre><code class="language-bash">nebula verify MyApp.dll --require rename,stringEncryption,controlFlow
</code></pre>
<figure class="my-8">nebula protectbuild + hardenprotection reportrequested vs appliednebula verifycompare + exit codeexit 0 — continueall transforms landedexit 3 — fail builda transform was skipped</figure>

<h3>Wiring it into a pipeline</h3>
<p>In a shell-based CI step, the gate is one line — <code>set -e</code> and a non-zero exit do the rest:</p>
<pre><code class="language-yaml"># GitHub Actions
- name: Harden and verify
  run: |
    nebula protect src/bin/Release/net8.0/MyApp.dll \
      --report artifacts/nebula-report.json
    nebula verify src/bin/Release/net8.0/MyApp.dll \
      --report artifacts/nebula-report.json

- name: Archive protection report
  uses: actions/upload-artifact@v4
  with:
    name: nebula-report
    path: artifacts/nebula-report.json
</code></pre>
<p>Prefer MSBuild? Make the gate part of the build graph so a skipped transform fails <code>dotnet build</code>:</p>
<pre><code class="language-xml">&lt;Target Name="VerifyProtection" AfterTargets="Nebula"&gt;
  &lt;Exec Command="nebula verify &amp;quot;$(TargetPath)&amp;quot; --report &amp;quot;$(OutDir)nebula-report.json&amp;quot;" /&gt;
&lt;/Target&gt;
</code></pre>
<p>Because <code>Exec</code> fails the target on a non-zero exit code, an exit <code>3</code> from <code>verify</code> stops the build with the rest of your pipeline.</p>
<h2>Archive it, then diff it</h2>
<p>The report earns its keep over time. Upload <code>nebula-report.json</code> as a build artifact on every run and you get a protection history you can diff:</p>
<pre><code class="language-bash"># Fail if this build renamed far fewer members than the last green build.
prev=$(jq '.summary.renameRatio' baseline-report.json)
curr=$(jq '.summary.renameRatio' artifacts/nebula-report.json)
awk -v p="$prev" -v c="$curr" 'BEGIN { exit !(c &gt;= p - 0.05) }'
</code></pre>
<p>A build-to-build diff catches the slow regressions <code>verify</code> alone cannot: a keep rule someone widened last month, a new public type that quietly enlarged your residual surface, a transform that still <em>applies</em> but now covers 60% of methods instead of 95%. The transform either landed or it didn't is a yes/no question; the ratios are a trend, and trends are where protection silently erodes.</p>
<h2>One more guard: <code>nebula doctor</code></h2>
<p>Most silent-fallback failures trace back to a keep rule that is too broad. <code>nebula doctor</code> analyzes your configuration against the assembly and flags rules that match far more than they probably should:</p>
<pre><code class="language-bash">nebula doctor MyApp.dll
</code></pre>
<p>It will tell you, for example, that <code>keep: MyApp.*</code> is holding 400 of 412 types legible — almost certainly not what you intended when you only meant to preserve a plugin contract. Run it when <code>renameRatio</code> looks low or <code>residualPublicSurface</code> is longer than you expected; it turns "why isn't this being renamed?" into a direct answer.</p>
<h2>Takeaway</h2>
<p>Obfuscation that runs but does not apply is worse than no obfuscation, because it ships with a false sense of security. Nebula.NET 2.2.0 makes that failure impossible to miss: emit the <strong>protection report</strong> on every build so you can see exactly what landed, run <strong><code>nebula verify</code></strong> as a gate so a skipped transform fails the build on a dedicated exit code, archive and diff the report so slow erosion shows up as a trend, and keep <strong><code>nebula doctor</code></strong> handy when a keep rule is doing more than you asked. Protect the software you have the right to protect — and prove, on every build, that you actually did.</p>
]]></content:encoded></item><item><title><![CDATA[Licensing AI features and autonomous agents with signed, offline entitlements]]></title><description><![CDATA[A license used to answer one question about AI features: may this customer use them at all? That is no longer enough. An AI product today is a set of…]]></description><link>https://delta1labs.hashnode.dev/licensing-ai-features-and-autonomous-agents-with-signed-offline-entitlements</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/licensing-ai-features-and-autonomous-agents-with-signed-offline-entitlements</guid><category><![CDATA[AI licensing]]></category><category><![CDATA[keyright]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Thu, 12 Nov 2026 05:43:47 GMT</pubDate><content:encoded><![CDATA[<p>A license used to answer one question about AI features: may this customer use them at all? That is no longer enough. An AI product today is a set of distinct, separately-priced capabilities — which models, which providers, which tools an agent may call — and an autonomous agent is a process that spends real money on every step it takes. "Licensed: yes" says nothing about whether this customer's agent may call <code>gpt-5</code>, reach a third-party endpoint, invoke a shell tool, or burn ten thousand credits in a loop overnight.</p>
<p>Keyright 2.9.0 makes those the first-class things a license grants. AI and agent entitlements name the models, endpoints and tools a build may touch, and bound an autonomous agent with a credit budget and a lease — all enforced <strong>fail-closed</strong> and <strong>offline</strong> from the signed license, with an opt-in metered <code>POST /v1/consume</code> for running usage across instances.</p>
<h2>The entitlement shape</h2>
<p>Six conventional keys sit on a license tier as a template; issuing a license at that tier bakes them into the signed lease the customer activates.</p>
<ul>
<li><code>ai.enabled</code> — the master switch. Boolean. When <code>false</code>, nothing else matters.</li>
<li><code>ai.models</code> — an allow-list of model identifiers the build may call, e.g. <code>openai/gpt-5</code>, <code>anthropic/claude-sonnet-4.6</code>.</li>
<li><code>ai.endpoints</code> — the API hosts a build may reach, e.g. <code>api.openai.com</code>.</li>
<li><code>ai.tools</code> — the tool names an agent may invoke, e.g. <code>web.search</code>, <code>code.run</code>.</li>
<li><code>agent.budget.credits</code> — the hard ceiling of credits one agent may spend.</li>
<li><code>agent.lease.seconds</code> — how long an agent may run before its lease must be renewed.</li>
</ul>
<p>The three list-valued keys are <strong>allow-lists with wildcards</strong>. A <code>*</code> matches any single segment, so <code>openai/gpt-*</code> admits <code>openai/gpt-5</code> and <code>openai/gpt-5-mini</code> but not <code>openai/o3</code>; a bare <code>*</code> admits everything in that dimension. Allow-lists fail closed: a model, endpoint or tool that matches nothing on the list is denied. You grant by naming; you never have to enumerate what is forbidden.</p>
<h2>A signed license, carrying AI entitlements</h2>
<p>Here is what a Pro-tier agent license looks like once issued. The <code>ent</code> block is the entitlement template baked in at issue time; the whole payload is signed, so every value below is tamper-evident.</p>
<pre><code class="language-json">{
  "sub": "acme-corp",
  "product": "atlas-agent",
  "tier": "pro",
  "exp": "2027-11-12T00:00:00Z",
  "ent": {
    "ai.enabled": true,
    "ai.models": ["openai/gpt-5", "openai/gpt-5-mini", "anthropic/claude-*"],
    "ai.endpoints": ["api.openai.com", "api.anthropic.com"],
    "ai.tools": ["web.search", "code.run", "file.read"],
    "agent.budget.credits": 50000,
    "agent.lease.seconds": 3600
  },
  "sig": "ed25519:9f2c…c41a"
}
</code></pre>
<p>Note what this grants: any current Claude model (<code>anthropic/claude-*</code>) but only two specific OpenAI models; two provider endpoints; three tools; fifty thousand credits per agent; a one-hour lease. Change any character — add a model, raise the ceiling — and the Ed25519 signature no longer verifies against the public key the app embeds at build time. The check fails closed rather than silently granting more.</p>
<figure class="my-8">Agent stepmodel + tool callSigned licenseai.models / ai.toolsverify offline (fail-closed)alloweddeniedEnforceModel → throwproceedPOST/v1/consumebudget 50000− credits→ remaining</figure>

<h2>Reading and enforcing in your app</h2>
<p>The SDK parses the entitlements from the verified lease once, then answers synchronously with no network call. In C#, the AI entitlements hang off <code>info.Ai</code>:</p>
<pre><code class="language-csharp">var client = KeyrightClient.Initialize(options);
var info = await client.ActivateAsync(licenseKey);   // lease cached locally

// Guard the master switch first.
if (!info.Ai.Enabled)
    throw new FeatureNotLicensedException("ai");

// Soft check — branch without throwing.
if (info.Ai.AllowsModel("openai/gpt-5"))
    useModel("openai/gpt-5");

// Hard check — throws ModelNotLicensedException when the model is not allowed.
info.Ai.EnforceModel(requestedModel);

// Endpoints and tools work the same way.
info.Ai.EnforceEndpoint("api.openai.com");
info.Ai.EnforceTool("code.run");

// The per-agent budget ceiling is readable offline.
long ceiling = info.AgentBudgetCredits;   // 50000, straight from the signed lease
</code></pre>
<p><code>AllowsModel</code> returns a boolean after matching the identifier against <code>ai.models</code>, wildcards included; <code>EnforceModel</code> is the throwing form you put at the boundary of the call itself. Both fail closed: an unknown model is denied, and a lease with no <code>ai.models</code> key grants nothing.</p>
<p>The same surface exists in the other SDKs — same identifiers, same wildcard semantics, idiomatic casing:</p>
<pre><code class="language-javascript">// Node
if (info.ai.allowsModel("anthropic/claude-sonnet-4.6")) { /* ... */ }
info.ai.enforceModel(requestedModel);        // throws when not allowed
</code></pre>
<pre><code class="language-python"># Python
if info.ai.allows_model("anthropic/claude-sonnet-4.6"):
    ...
info.ai.enforce_model(requested_model)        # raises when not allowed
</code></pre>
<pre><code class="language-java">// Java
if (info.ai().allowsModel("anthropic/claude-sonnet-4.6")) { /* ... */ }
info.ai().enforceModel(requestedModel);       // throws when not allowed
</code></pre>
<h2>Metering a per-agent credit budget</h2>
<p><code>agent.budget.credits</code> is the <strong>offline ceiling</strong> — a hard cap an air-gapped agent self-limits against. But a real agent fleet shares one budget across many concurrent instances, and each needs to know the <em>running</em> total, not just the cap. That is what <code>POST /v1/consume</code> meters.</p>
<p>Before (or after) a unit of work, the agent reports what it spent. The service decrements a shared counter keyed to the license and returns the remaining balance; when a charge would take the balance below zero it is rejected, and the agent stops. The call is the only part of AI enforcement that touches the network, and only when you opt into shared metering.</p>
<pre><code class="language-csharp">// Charge the shared budget for one step. Fails closed when exhausted.
var result = await client.ConsumeAsync(new ConsumeRequest
{
    AgentId = agentId,
    Credits = step.EstimatedCredits
});

if (!result.Granted)
    throw new AgentBudgetExhaustedException(result.Remaining);  // Remaining == 0

logger.LogInformation("Agent {Agent} spent {Spent}; {Left} credits left",
    agentId, step.EstimatedCredits, result.Remaining);
</code></pre>
<p>The offline ceiling and the online meter compose. An agent that cannot reach the service still cannot exceed <code>info.AgentBudgetCredits</code> locally; an agent that can reach it additionally cannot exceed the shared balance, so ten instances cannot each spend the full ceiling. <code>agent.lease.seconds</code> bounds the other axis: a runaway agent whose lease has lapsed stops regardless of remaining credits.</p>
<h2>A concrete gating scenario</h2>
<p>Put it together. A customer on the license above spins up an agent and, on step three, the planner asks for <code>openai/o3</code> — a model not on their allow-list — then requests a 40,000-credit batch.</p>
<pre><code class="language-csharp">public async Task RunStep(AgentStep step)
{
    // 1. Deny the unlicensed model, before any provider call or spend.
    if (!info.Ai.AllowsModel(step.Model))
        throw new ModelNotLicensedException(step.Model);   // "openai/o3" is denied

    // 2. Enforce the tool and endpoint the step wants.
    info.Ai.EnforceTool(step.Tool);
    info.Ai.EnforceEndpoint(step.Endpoint);

    // 3. Meter the spend against the shared per-agent budget.
    var charge = await client.ConsumeAsync(new ConsumeRequest
    {
        AgentId = step.AgentId,
        Credits = step.EstimatedCredits        // 40000 requested
    });
    if (!charge.Granted)
        throw new AgentBudgetExhaustedException(charge.Remaining);

    await Execute(step);
}
</code></pre>
<p><code>openai/o3</code> is rejected at line one — offline, before a single token is bought — because it matches nothing in <code>ai.models</code>. Had the planner asked for <code>openai/gpt-5-mini</code> instead, the model check would pass, the tool and endpoint checks would pass, and the 40,000-credit charge would succeed against a fresh 50,000 ceiling — but a second such step would be rejected with <code>Remaining == 10000 &lt; 40000</code>, and the agent would stop rather than overspend. No build change, no redeploy: moving <code>openai/o3</code> into the plan, or raising the ceiling to 100,000, is a change to the tier template and a re-issued license.</p>
<h2>Takeaway</h2>
<p>Treat AI capabilities the way you already treat features: as named, signed entitlements your code checks locally. Let <code>ai.models</code>, <code>ai.endpoints</code> and <code>ai.tools</code> say what an agent may call and <code>agent.budget.credits</code> / <code>agent.lease.seconds</code> say how much and how long — enforce them fail-closed with <code>AllowsModel</code> / <code>EnforceModel</code> and meter shared spend through <code>POST /v1/consume</code>. The model allow-list, the tool allow-list and the budget ceiling all hold offline, straight from the signature; metering is the one thing you reach the network for, and only when a fleet shares a budget. Your packaging — which tier gets which models, how many credits — stays configuration on the server, not constants in your binary.</p>
]]></content:encoded></item><item><title><![CDATA[Decompile and inspect .NET assemblies from the command line — and from an AI assistant]]></title><description><![CDATA[Glass.NET is a free .NET decompiler built around navigation — you open an assembly and read it like your own source. But plenty of the work you do with a…]]></description><link>https://delta1labs.hashnode.dev/decompile-and-inspect-net-assemblies-from-the-command-line-and-from-an-ai-assistant</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/decompile-and-inspect-net-assemblies-from-the-command-line-and-from-an-ai-assistant</guid><category><![CDATA[cli]]></category><category><![CDATA[glassnet]]></category><category><![CDATA[mcp]]></category><category><![CDATA[dotnet]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Thu, 12 Nov 2026 05:42:23 GMT</pubDate><content:encoded><![CDATA[<p>Glass.NET is a free .NET decompiler built around <em>navigation</em> — you open an assembly and read it like your own source. But plenty of the work you do with a decompiler isn't interactive at all. You want to gate a CI job on whether a dependency changed its public surface, emit a software bill of materials for every binary you ship, or check that the NuGet package you just pulled is really what it claims to be. And increasingly you want your AI coding assistant to answer "what does this dependency actually do?" without you hand-feeding it decompiled snippets.</p>
<p>Glass.NET 1.3.0 covers both. It adds a <strong>headless CLI</strong> — the same ICSharpCode.Decompiler engine as the UI, no window required — and <code>glass mcp</code>, a <strong>Model Context Protocol server</strong> that lets an AI assistant open assemblies and read decompiled C# on your behalf. Everything below is strictly <strong>static analysis</strong>: Glass reads metadata and IL and reconstructs C#. It never runs the assembly it inspects. And all of it is <strong>free</strong> — no tiers, no license, no seats.</p>
<h2>The command surface</h2>
<p>Four commands do the batch and scripting work, plus the server:</p>
<ul>
<li><code>glass export</code> — reconstruct a buildable C# project, or dump a single member.</li>
<li><code>glass sbom</code> — emit a software bill of materials for an assembly or package.</li>
<li><code>glass verify</code> — check a NuGet package's authenticity and strong-name identity.</li>
<li><code>glass diff</code> — compare two builds and report what changed in the public surface.</li>
<li><code>glass mcp</code> — start the MCP server for an AI assistant.</li>
</ul>
<p>Run <code>glass --help</code> for the full list; every command takes <code>--json</code> so you can pipe output into <code>jq</code> or a build step.</p>
<h2>glass export — a buildable project, or one member</h2>
<p>Point <code>export</code> at an assembly and a directory and you get a reconstructed, compilable project back:</p>
<pre><code class="language-console">$ glass export ./Acme.Payments.dll -o ./out/Acme.Payments
Decompiling Acme.Payments.dll (net8.0) …
  142 types, 1,206 members
Wrote ./out/Acme.Payments/Acme.Payments.csproj
Wrote 142 source files
Done in 1.9s — static analysis only, assembly was not executed.
</code></pre>
<p>That <code>.csproj</code> targets the assembly's own TFM and lists its references, so <code>dotnet build ./out/Acme.Payments</code> just works — useful when you need to diff against source, patch-and-recompile, or hand a reproducible tree to someone.</p>
<p>When you only care about one method, skip the whole project and ask for the member by its documentation-comment ID:</p>
<pre><code class="language-console">$ glass export ./Acme.Payments.dll --member "M:Acme.Payments.Gateway.Charge(System.Decimal)"
</code></pre>
<pre><code class="language-csharp">public ChargeResult Charge(decimal amount)
{
    if (amount &lt;= 0m)
        throw new ArgumentOutOfRangeException(nameof(amount));
    var req = new ChargeRequest(amount, _clock.UtcNow);
    return _transport.Send(req);
}
</code></pre>
<p>You can pass a type ID (<code>T:Acme.Payments.Gateway</code>) to dump every member of a type, or <code>--il</code> to get the IL view instead of C#. This is the fast path for "show me exactly what this one method does" without opening a UI.</p>
<h2>glass sbom — a bill of materials for a binary</h2>
<p><code>glass sbom</code> reads an assembly's metadata and produces a dependency inventory in CycloneDX JSON — the assembly's identity, its target framework, and every <code>AssemblyRef</code> it binds against, with versions and public-key tokens:</p>
<pre><code class="language-console">$ glass sbom ./Acme.Payments.dll --format cyclonedx -o payments.sbom.json
$ jq '.components[] | {name, version}' payments.sbom.json
{ "name": "Newtonsoft.Json", "version": "13.0.3" }
{ "name": "System.Text.Json", "version": "8.0.0" }
{ "name": "Acme.Core", "version": "4.2.1" }
</code></pre>
<p>Because it works from the compiled binary rather than a <code>packages.lock.json</code>, it tells you what the shipped artifact <em>actually</em> references — including transitive assemblies that a project-file audit can miss. Point it at a <code>.nupkg</code> and Glass enumerates the assemblies inside each <code>lib/&lt;tfm&gt;</code> folder and merges their references into one document.</p>
<h2>glass verify — is this package what it claims to be?</h2>
<p>Before you trust a package, <code>glass verify</code> checks two things statically: the NuGet signature on the <code>.nupkg</code>, and the strong-name identity of the assemblies inside it.</p>
<pre><code class="language-console">$ glass verify ./Acme.Payments.4.2.1.nupkg
Package     Acme.Payments 4.2.1
Signature   author-signed, counter-signed (timestamp 2026-10-30)  ✓
Assemblies
  Acme.Payments.dll   strong-named  pkt=b77a5c561934e089   ✓
  Acme.Core.dll       NOT strong-named                     ⚠
Verdict     signature valid; 1 assembly unsigned — review before trusting.
</code></pre>
<p>It reports whether the package is author- or repository-signed, whether the signature is intact, and which assemblies carry a strong name and which don't. It does <strong>not</strong> vouch for intent — a validly signed package can still do something you dislike — which is why <code>verify</code> pairs naturally with <code>export</code> to actually read the code. And, as with every command, verification reads bytes; it never runs the package.</p>
<h2>glass diff — what changed between two builds</h2>
<p>Hand <code>diff</code> two versions of the same assembly and it reports the delta in the public API surface:</p>
<pre><code class="language-console">$ glass diff ./Acme.Payments-4.2.0.dll ./Acme.Payments-4.2.1.dll
+ public ChargeResult Gateway.Charge(decimal, Idempotency)   added overload
- internal void Gateway.ResetCounters()                       removed
~ public sealed class ChargeResult                            field layout changed
3 changes (1 added, 1 removed, 1 modified)
</code></pre>
<p>With <code>--json</code> this becomes a gate: fail the build when a dependency bump quietly changes a signature you depend on, or flag an internal-to-public promotion in review.</p>
<h2>A worked example: vet a NuGet package before trusting it</h2>
<p>Say a new transitive dependency, <code>Contoso.Telemetry</code>, showed up in a lockfile and you want to know what it does before it ships. The whole audit is static — nothing here executes the package:</p>
<pre><code class="language-console"># 1. Download the package without installing it into a project.
$ nuget install Contoso.Telemetry -Version 2.1.0 -OutputDirectory ./pkgs -DependencyVersion Ignore

# 2. Is it signed, and are its assemblies strong-named?
$ glass verify ./pkgs/Contoso.Telemetry.2.1.0/Contoso.Telemetry.2.1.0.nupkg
Signature   author-signed  ✓
Assemblies  Contoso.Telemetry.dll  strong-named  ✓

# 3. What does it reference? (Watch for networking / reflection you didn't expect.)
$ glass sbom ./pkgs/Contoso.Telemetry.2.1.0/lib/net8.0/Contoso.Telemetry.dll --json \
    | jq -r '.components[].name'
System.Net.Http
System.Text.Json

# 4. It binds System.Net.Http — read the code that uses it.
$ glass export ./pkgs/Contoso.Telemetry.2.1.0/lib/net8.0/Contoso.Telemetry.dll \
    --member "M:Contoso.Telemetry.Beacon.Flush"
</code></pre>
<p>Four commands, no window, nothing executed — and you've gone from "an unknown dependency appeared" to reading the exact method that touches the network.</p>
<p>The flow that ties it together:</p>
<figure class="my-8">.nupkguntrustedglass verifyglass sbomglass exportstatic inspectionmetadata + IL → C#glass mcpAI assistant readsdecompiled C#</figure>

<h2>glass mcp — hand the decompiler to your AI assistant</h2>
<p>The same static inspection is available to an AI coding assistant through the <a href="https://modelcontextprotocol.io">Model Context Protocol</a>. <code>glass mcp</code> starts a JSON-RPC server over stdio that exposes a small set of read-only tools:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>glass_open</code></td>
<td>Open an assembly or <code>.nupkg</code> and return its identity + TFM</td>
</tr>
<tr>
<td><code>glass_list_types</code></td>
<td>Enumerate namespaces, types and members</td>
</tr>
<tr>
<td><code>glass_decompile</code></td>
<td>Return reconstructed C# for a type or member ID</td>
</tr>
<tr>
<td><code>glass_find_references</code></td>
<td>List callers of a member, across the open assembly</td>
</tr>
<tr>
<td><code>glass_sbom</code></td>
<td>Return the dependency inventory as structured JSON</td>
</tr>
</tbody></table>
<p>Register it with any MCP-capable assistant by pointing <code>command</code> at <code>glass</code>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "glass": { "command": "glass", "args": ["mcp"] }
  }
}
</code></pre>
<p>Now, instead of you decompiling a method and pasting it into chat, the assistant does it itself. A <code>tools/call</code> for <code>glass_decompile</code> looks like this on the wire:</p>
<pre><code class="language-json">{ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
  "params": { "name": "glass_decompile",
    "arguments": { "member": "M:Contoso.Telemetry.Beacon.Flush" } } }
</code></pre>
<p>and the server answers with the reconstructed C# as text content:</p>
<pre><code class="language-json">{ "jsonrpc": "2.0", "id": 7,
  "result": { "content": [ { "type": "text",
    "text": "private async Task Flush(CancellationToken ct)\n{\n    using var http = new HttpClient();\n    await http.PostAsync(_endpoint, Serialize(_buffer), ct);\n}" } ] } }
</code></pre>
<p>So you can ask your assistant, in plain language, <em>"open Contoso.Telemetry 2.1.0 and tell me what <code>Beacon.Flush</code> sends and where"</em> — and it opens the assembly, reads the decompiled method, and tells you it POSTs a serialized buffer to a configured endpoint. The assistant is reading <strong>static</strong> output: <code>glass mcp</code> decompiles, it does not execute, so even an assistant acting on your behalf can inspect an untrusted package without running it.</p>
<h2>Takeaway</h2>
<p>Glass.NET 1.3.0 turns decompilation from something you do by hand in a window into something you can <strong>script</strong> and <strong>delegate</strong>. Use <code>glass export</code> for a buildable tree or a single method, <code>glass sbom</code> for a bill of materials, <code>glass verify</code> to check a package's signature and strong names, and <code>glass diff</code> to gate on public-surface changes in CI. When you'd rather ask a question than run a command, <code>glass mcp</code> gives your AI assistant the same read-only, static view. Every one of these reads the binary and reconstructs C# — none of them ever run it — and all of it is free. Grab the latest build from the <a href="https://delta1labs.com/products/glass">Glass.NET download page</a> and point it at the next dependency you're unsure about.</p>
]]></content:encoded></item><item><title><![CDATA[Anti-Dump for .NET: Stopping Attackers From Lifting Your Assembly Out of Memory]]></title><description><![CDATA[String encryption, control-flow flattening, renaming — they all protect the assembly on disk. But your program does not run on disk; it runs in memory, and…]]></description><link>https://delta1labs.hashnode.dev/anti-dump-for-net-stopping-attackers-from-lifting-your-assembly-out-of-memory</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/anti-dump-for-net-stopping-attackers-from-lifting-your-assembly-out-of-memory</guid><category><![CDATA[obfuscation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 06:23:09 GMT</pubDate><content:encoded><![CDATA[<p>String encryption, control-flow flattening, renaming — they all protect the assembly <em>on disk</em>. But your program does not run on disk; it runs in memory, and by the time it is running the CLR has loaded the image, the JIT has compiled methods, and your encrypted strings have very likely been decrypted into RAM so the code could use them. An attacker who dumps the process memory captures that live image, and with it much of what your on-disk obfuscation was hiding. Anti-dump protection is the layer that addresses this specific gap, and understanding it clarifies why it is a necessary complement to — not a replacement for — the rest.</p>
<h2>The dump attack</h2>
<p>A memory-dumping tool (the genre that MegaDumper and similar represent) does something conceptually simple: attach to or scan your running process, locate the loaded .NET module in its address space, and write those bytes out as a <code>.dll</code>. Because it copies the <em>loaded</em> image, it gets:</p>
<ul>
<li>strings that string-encryption decrypted at runtime, now sitting in the clear,</li>
<li>the real IL for methods the JIT needed (for non-virtualized code),</li>
<li>a module that can often be loaded straight into a decompiler.</li>
</ul>
<p>The attacker never had to defeat your on-disk file. They let your own process do the decryption and then photographed the result. This is why "I encrypted my strings" is not, by itself, enough.</p>

  Anti-dump corrupts the in-memory PE headers after load
  
  
  Module loadedheaders + IL in RAM
  CLR readheaders
  Anti-dump: wipeDOS/NT headers, sections
  attackerdumps
  Dump = headerless blobnot a valid/loadable PE
  The running code already resolved what it needs; the dumper gets an image missing the structure a loader requires.


<h2>How anti-dump defends the loaded image</h2>
<p>The key insight behind anti-dump is that a PE's headers are read <strong>once</strong>, by the loader, at load time. After the CLR has parsed the DOS header, NT headers, section table, and the CLI/metadata directory, the already-running code does not consult them again — the JIT works from structures the runtime has already built. So those header regions are, from the running program's perspective, dead weight after startup, while from a <em>dumper's</em> perspective they are essential: without valid headers, the dumped bytes are not a recognizable assembly.</p>
<p>Anti-dump exploits that asymmetry. A small routine, injected and run early, locates the module's base address in memory (for example via the module handle / <code>Marshal.GetHINSTANCE</code>) and <strong>zeroes or scrambles the header regions</strong> — the DOS and NT headers, the section table, and the metadata directory pointers. Your program keeps running without noticing. A tool that then dumps the module copies a blob whose front matter is destroyed, so it does not load and does not open in a decompiler until someone manually reconstructs the headers from scratch — a far cry from one-click dumping.</p>
<h2>Honest limits, and layering</h2>
<p>Anti-dump is a <strong>speed bump, not a wall</strong>, and it is important to say so plainly. A determined attacker can rebuild PE headers by hand, or dump at a moment before the wipe runs, or reconstruct metadata from the live runtime. Its value is defeating the <em>easy</em> path — the automated dumpers that make lifting an assembly a thirty-second job — and raising the cost of the hard path.</p>
<p>That is exactly why it belongs in a stack, not alone:</p>
<ul>
<li><strong><a href="https://delta1labs.com/blog/string-encryption-dotnet/">String encryption</a></strong> keeps secrets off disk; anti-dump keeps the decrypted form harder to capture.</li>
<li><strong><a href="https://delta1labs.com/blog/anti-debugging-dotnet/">Anti-debugging</a></strong> and <strong><a href="https://delta1labs.com/blog/anti-tamper-dotnet/">anti-tamper</a></strong> make the process hostile to the tools a dumper is usually paired with.</li>
<li><strong><a href="https://delta1labs.com/blog/nebula-1-1-code-virtualization/">Code virtualization</a></strong> ensures that even a successful dump yields virtualized methods an attacker still cannot read.</li>
</ul>
<p>Layered, a dump becomes expensive to take and worth little once taken.</p>
<h2>Doing this with Nebula</h2>
<p><a href="https://delta1labs.com/nebula">Nebula</a> applies anti-dump conservatively — corrupting only the header regions the runtime has finished with — so the protection is transparent to your app while a casual memory dump yields an unusable blob. It composes with Nebula's string encryption, anti-debugging, anti-tamper, and (on <strong>Pro</strong>) <a href="https://delta1labs.com/blog/nebula-1-1-code-virtualization/">code virtualization</a>, so the in-memory image is defended at the same depth as the on-disk file. Where an assembly legitimately reloads its own image at runtime, you can exclude it.</p>
<p>Start with the <a href="https://delta1labs.com/blog/free-dotnet-obfuscator/">free edition</a>, or see the <a href="https://delta1labs.com/pricing?product=nebula">plans</a> for the full in-memory protection stack.</p>
]]></content:encoded></item><item><title><![CDATA[Licensing Software That Runs on Ephemeral CI and Cloud Build Agents Without Burning Seats]]></title><description><![CDATA[Licensing models were mostly designed for machines that stay put — a desktop, a server, a workstation. CI and cloud build agents violate that assumption on…]]></description><link>https://delta1labs.hashnode.dev/licensing-software-that-runs-on-ephemeral-ci-and-cloud-build-agents-without-burning-seats</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/licensing-software-that-runs-on-ephemeral-ci-and-cloud-build-agents-without-burning-seats</guid><category><![CDATA[licensing]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[guide]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 06:21:02 GMT</pubDate><content:encoded><![CDATA[<p>Licensing models were mostly designed for machines that stay put — a desktop, a server, a workstation. CI and cloud build agents violate that assumption on purpose: they exist for the duration of one job and then vanish, and the next job gets a brand-new VM or container. Point a node-locked license at that environment and it does exactly the wrong thing — every build looks like a new machine, activates, consumes a seat, and then the machine is destroyed without ever releasing it. A ten-seat license can be exhausted before lunch. The fix is not a bigger seat count; it is licensing the dimension that actually varies.</p>
<h2>Why ephemeral breaks node-locking</h2>
<p><a href="https://delta1labs.com/blog/node-locking-seat-management/">Node-locking</a> derives a fingerprint from a machine's hardware and binds the license to it, so a seat represents a distinct device. That is exactly right for desktops and exactly wrong for ephemeral compute, where "distinct device" is a meaningless unit — there is a new one every few minutes, none of them lasts, and the thing you actually want to limit is not <em>how many machines</em> but <em>how much concurrent (or total) use</em>.</p>
<p>Worse, the cleanup assumption fails too. Node-locked seats are normally freed by an explicit <a href="https://delta1labs.com/blog/license-deactivation-transfer-offline-receipt/">deactivation</a> when a user moves machines. A CI runner is not deactivated; it is deleted. So its seat lingers until something reclaims it, and in a busy pipeline seats are consumed far faster than any manual process frees them.</p>

  Ephemeral runners against node-locked vs floating licensing
  
  Ephemeral runners (new fingerprint each run):
  run #1
  run #2
  run #3 …
  
  Node-locked: each run burns a seat,none released → seats exhausted
  
  Floating/concurrency: counts simultaneous use,dead runner's lease auto-reaped → stable
  Same workload, two models:


<h2>Three models that fit ephemeral compute</h2>
<p>Pick by what you actually mean to limit.</p>
<p><strong>Floating / concurrency.</strong> License the number of <em>simultaneous</em> uses, not machines. A pool of N concurrent leases means at most N builds run at once; a finished (or destroyed) runner's lease is reclaimed and the next build takes it. This is the natural fit when parallelism is what you are selling — see <a href="https://delta1labs.com/blog/floating-concurrent-licenses-explained/">floating concurrent licenses</a>. Pair it with <a href="https://delta1labs.com/blog/license-heartbeat-lease-reaping/">heartbeat lease reaping</a> so a runner that vanishes without deactivating has its lease freed after a short timeout rather than lingering.</p>
<p><strong>Build-scoped entitlement.</strong> If you only need to <em>gate the feature</em> in CI (prove the customer is licensed, without counting machines), validate a signed <a href="https://delta1labs.com/blog/license-entitlements-feature-flags/">entitlement</a> with a token that does not consume a seat at all. The build checks the entitlement offline and runs; nothing is node-locked.</p>
<p><strong>Metered builds.</strong> If the fair unit is total runs, meter them — count builds as <a href="https://delta1labs.com/blog/usage-based-licensing-metering/">usage</a> against a quota, with an idempotency key so a retried build does not double-count.</p>
<h2>Detecting CI and taking the right path</h2>
<p>The implementation hinge is letting your software know it is in CI so it takes the ephemeral path instead of the desktop one:</p>
<pre><code class="language-csharp">static bool IsCi() =&gt;
    Environment.GetEnvironmentVariable("CI") is "true" or "1"
    || Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true"
    || Environment.GetEnvironmentVariable("GITLAB_CI") == "true"
    || Environment.GetEnvironmentVariable("TF_BUILD") == "true"      // Azure Pipelines
    || Environment.GetEnvironmentVariable("JENKINS_URL") is not null;

// In CI, validate against a concurrency/entitlement path — not a node-locked activation.
var result = IsCi()
    ? keyright.ValidateFloating()   // or a build-scoped entitlement check
    : keyright.Activate();          // normal desktop node-lock
</code></pre>
<p>The desktop experience is unchanged; the CI path simply validates the dimension that makes sense there and never writes a per-machine activation that would strand a seat.</p>
<h2>Doing this with Keyright</h2>
<p><a href="https://delta1labs.com/keyright">Keyright</a> supports all three models — floating concurrency with heartbeat reaping, build-scoped entitlements validated without consuming a seat, and metered build counting — so you can license ephemeral CI and autoscaled agents by concurrency or usage instead of by machine, and never watch a pipeline exhaust your seats. Each path validates offline against a signed payload, so a runner with restricted egress still works. Keyright is self-hosted, so your entitlements and usage data stay on infrastructure you control.</p>
<p>See the <a href="https://delta1labs.com/pricing?product=keyright">plans</a>, or start from <a href="https://delta1labs.com/blog/floating-concurrent-licenses-explained/">floating concurrent licenses</a> and add CI detection to route runners onto it.</p>
]]></content:encoded></item><item><title><![CDATA[Decompiling P/Invoke and DllImport: Reading a .NET Assembly''s Native Interop]]></title><description><![CDATA[When you need to understand what an unfamiliar .NET assembly actually does — not to its own data, but to the machine — the native interop is where the…]]></description><link>https://delta1labs.hashnode.dev/decompiling-pinvoke-and-dllimport-reading-a-net-assemblys-native-interop</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/decompiling-pinvoke-and-dllimport-reading-a-net-assemblys-native-interop</guid><category><![CDATA[decompilation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[guide]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 06:18:29 GMT</pubDate><content:encoded><![CDATA[<p>When you need to understand what an unfamiliar .NET assembly actually <em>does</em> — not to its own data, but to the machine — the native interop is where the answer lives. Managed code that stays inside the CLR is sandboxed by it; the moment it calls into native code via P/Invoke, it can do anything the process can. Fortunately, P/Invoke declarations are among the most faithfully recoverable things in a decompiled assembly, because the information a decompiler needs is sitting in a dedicated metadata table. Reading it well turns "this assembly calls some native stuff" into a precise inventory.</p>
<h2>How P/Invoke is stored</h2>
<p>A <code>[DllImport]</code> method does not carry a method body — it is an <code>extern</code> declaration. In the assembly it becomes a method flagged <code>pinvokeimpl</code>, with an entry in the <strong>ImplMap</strong> metadata table that records four things: the <strong>module</strong> (the native DLL), the <strong>entry-point name</strong>, the <strong>calling convention</strong>, and the <strong>character set / name-mangling</strong> rules. The managed signature (parameter and return types, plus any <code>[MarshalAs]</code>) lives in the normal method signature and parameter metadata.</p>
<p>A decompiler like <a href="https://delta1labs.com/glass">Glass</a> reads the ImplMap and the signature and reconstructs the original declaration — attribute and all:</p>
<pre><code class="language-csharp">[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true,
           CallingConvention = CallingConvention.Winapi)]
static extern IntPtr CreateFileW(
    [MarshalAs(UnmanagedType.LPWStr)] string lpFileName,
    uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes,
    uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
</code></pre>
<p>Everything in that reconstruction — the DLL name, <code>CharSet</code>, <code>SetLastError</code>, <code>CallingConvention</code>, and the <code>[MarshalAs]</code> on the string — came from metadata, not from guesswork.</p>

  DllImport is reconstructed from the ImplMap metadata
  
  
  pinvokeimpl method+ ImplMap entry
  
  ImplMap: DLL, entry,calling conv., charset+ [MarshalAs] on params
  
  Glass → [DllImport] C#full native declaration
  The ImplMap table is a complete index of every native function the assembly can call.


<h2>Building a native-call inventory</h2>
<p>Because every P/Invoke is an ImplMap entry, you can enumerate an assembly's entire native surface. In Glass, open the assembly and look at its P/Invoke methods: each one names a DLL and a function. That inventory answers the questions that matter when you are auditing something you did not write:</p>
<ul>
<li><strong>Which native libraries does it touch?</strong> <code>kernel32</code>/<code>user32</code> is routine; a bundled unknown <code>.dll</code>, <code>ws2_32</code> (sockets), or <code>advapi32</code> crypto/registry calls are worth a closer look.</li>
<li><strong>What can it do to the system?</strong> File, process, registry, and network functions are all visible by name.</li>
<li><strong>How does data cross the boundary?</strong> The <code>[MarshalAs]</code>, <code>[StructLayout]</code>, and <code>[FieldOffset]</code> metadata reconstruct the ABI contract, so you can see how a managed struct maps to the native layout a function expects.</li>
</ul>
<p>This is the same workflow as <a href="https://delta1labs.com/blog/analyze-suspicious-dotnet-assembly/">analyzing a suspicious assembly</a>, with native interop as the lens.</p>
<h2>The modern twist: LibraryImport</h2>
<p>.NET 7 introduced <code>[LibraryImport]</code>, a source-generated alternative to <code>[DllImport]</code>. Instead of the runtime marshalling arguments, a source generator emits the marshalling <strong>as C# at compile time</strong>: a <code>partial</code> method you declared, plus a generated stub that marshals the arguments and calls a private, <code>DllImport</code>-style native target. Decompiled, a <code>[LibraryImport]</code> therefore shows up as <em>more</em> code than a classic P/Invoke — the generated stub is right there — which is actually a bonus for reading: you see exactly how each argument is converted, rather than trusting the runtime to do it. It reads much like any <a href="https://delta1labs.com/blog/decompile-lambdas-closures-display-classes/">source-generated code</a> does once lowered.</p>
<h2>Doing this with Glass</h2>
<p><a href="https://delta1labs.com/glass">Glass</a> reconstructs <code>[DllImport]</code> declarations from the ImplMap — DLL, entry point, calling convention, charset, and <code>[MarshalAs]</code> — and shows the generated stubs behind <code>[LibraryImport]</code>, so you can read an assembly's complete native-interop surface and the exact marshalling of every call. It is the fastest way to answer "what does this assembly do to the machine?" and it is <a href="https://delta1labs.com/blog/free-dotnet-decompiler-glass/">free</a>.</p>
<p>Point Glass at an assembly and list its P/Invoke methods to see its native reach at a glance.</p>
]]></content:encoded></item><item><title><![CDATA[OEM, White-Label, and Reseller Licensing: Letting Partners Provision Keys Without Your Admin Token]]></title><description><![CDATA[Direct licensing has one issuer: you. OEM, white-label, and reseller channels break that assumption — now partners issue keys to their customers, under…]]></description><link>https://delta1labs.hashnode.dev/oem-white-label-and-reseller-licensing-letting-partners-provision-keys-without-your-admin-token</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/oem-white-label-and-reseller-licensing-letting-partners-provision-keys-without-your-admin-token</guid><category><![CDATA[licensing]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[guide]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 06:11:55 GMT</pubDate><content:encoded><![CDATA[<p>Direct licensing has one issuer: you. OEM, white-label, and reseller channels break that assumption — now partners issue keys to <em>their</em> customers, under their brand, on their schedule, and you are not in the loop for each sale. The naive solutions are both wrong: handing partners your admin token gives them the keys to your whole kingdom, and routing every partner sale through your team manually does not scale and defeats the point of a channel. The right model is <strong>scoped, attributed, capped provisioning</strong> — and it is mostly a matter of what you put in a token and in the signed key.</p>
<h2>The three problems a channel creates</h2>
<p>Selling through partners raises three distinct questions that direct licensing never has to answer:</p>
<ol>
<li><strong>Authority</strong> — how does a partner mint keys without being able to mint <em>anything</em>? They must be confined to their product, their tier, their quota.</li>
<li><strong>Attribution</strong> — when a key activates, which partner provisioned it? You need this for revenue share, royalties, and scoped support, and it must be forgery-proof.</li>
<li><strong>Isolation</strong> — a partner must never see, revoke, or interfere with another partner's (or your direct) customers.</li>
</ol>
<p>All three are solved by two mechanisms: a <strong>scoped provisioning token</strong> per partner, and a <strong>partner claim</strong> inside the signed key.</p>

  Scoped provisioning: vendor to partner to end customer
  
  
  You (vendor)signing key + admin
  scoped token(product, cap, expiry)
  Partner / OEMprovisions within cap
  key w/ partner claim
  End customeractivates branded app
  The partner never holds your admin token; the key is signed by you and attributed to the partner, tamper-evident.


<h2>Scoped provisioning tokens</h2>
<p>Instead of your admin credential, each partner gets a <strong>provisioning token</strong> whose authority is bounded to exactly what the agreement allows:</p>
<ul>
<li>a <strong>product/tier</strong> it may issue (and no others),</li>
<li>a <strong>quota</strong> — a maximum number of keys or seats, or a value cap,</li>
<li>an <strong>expiry</strong> on the token itself,</li>
<li>and no read/revoke access to anything it did not create.</li>
</ul>
<p>The partner calls a provisioning endpoint with that token to mint a key on demand (or you pre-generate a block and hand it over). The issuing service enforces the quota as they go and refuses once it is reached. A partner who leaks or abuses their token can only ever damage their own allocation — never your catalog, your pricing, or another partner's customers.</p>
<pre><code class="language-csharp">// Partner side: mint a key within the partner's scope (no admin token involved).
var key = await channel.ProvisionAsync(new ProvisionRequest {
    Product = "acme-suite",
    Tier    = "pro",
    Seats   = 5,
    Customer = "end-customer-ref",   // partner's own reference
});
// The service checks the partner token's quota/product scope and refuses if exceeded.
</code></pre>
<h2>The partner claim: attribution that cannot be forged</h2>
<p>Every key a partner issues carries a <strong>partner/reseller claim stamped into the signed payload</strong> — <code>partnerId</code>, and whatever else you need for accounting. Because it is inside the signature, it is tamper-evident (editing it breaks validation) and it travels with the key forever. That gives you, without any trust in the partner's own records:</p>
<ul>
<li><strong>Revenue share / royalties</strong> — sum activations by <code>partnerId</code>.</li>
<li><strong>Scoped support</strong> — a key presented to support identifies its partner instantly.</li>
<li><strong>Scoped revocation</strong> — revoke by <code>partnerId</code> to unwind a terminated partner, affecting only their keys (<a href="https://delta1labs.com/blog/license-revocation-strategies/">revocation strategies</a> apply per-partner).</li>
</ul>
<p>This pairs naturally with <a href="https://delta1labs.com/blog/keyright-product-claim-suites-bundles-one-key-many-apps/">product claim suites</a> when a partner resells a bundle, and with <a href="https://delta1labs.com/blog/license-entitlements-feature-flags/">entitlements</a> when their tier unlocks a different feature set than your direct one.</p>
<h2>White-label: making the vendor invisible</h2>
<p>A true white-label deployment goes one step further: the end customer should not see <em>you</em> at all. Two pieces make that work. The <strong>key format and activation messaging</strong> carry the partner's branding rather than yours. And each partner can be given its <strong>own signing identity (or product)</strong>, so the keys validate against a public key the partner embeds in their branded build — the cryptography is scoped to them. The licensing engine is yours; every surface the end customer touches is the partner's.</p>
<h2>Doing this with Keyright</h2>
<p><a href="https://delta1labs.com/keyright">Keyright</a> supports this channel model directly: issue each partner a scoped provisioning token (bounded by product, tier, and quota), stamp a partner claim into every signed key for forgery-proof attribution, and keep revocation and reporting scoped per partner — all without ever sharing your admin credentials. Partners provision on demand through the API or from pre-generated blocks, and white-label partners get their own signing identity so your name never appears. Because Keyright is self-hosted, the signing keys and all partner and customer data stay on infrastructure you control.</p>
<p>See the <a href="https://delta1labs.com/pricing?product=keyright">plans</a>, or start from <a href="https://delta1labs.com/blog/automate-license-fulfillment-webhooks/">automating license fulfillment</a> and add a partner scope to the issuance.</p>
]]></content:encoded></item><item><title><![CDATA[Obfuscating a ClickOnce-Deployed App: Why Order of Operations Decides Whether It Even Launches]]></title><description><![CDATA[There is a particular way a ClickOnce app fails after you add obfuscation that sends people down entirely the wrong rabbit hole: the app installs fine…]]></description><link>https://delta1labs.hashnode.dev/obfuscating-a-clickonce-deployed-app-why-order-of-operations-decides-whether-it-even-launches</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/obfuscating-a-clickonce-deployed-app-why-order-of-operations-decides-whether-it-even-launches</guid><category><![CDATA[obfuscation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[guide]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 06:10:45 GMT</pubDate><content:encoded><![CDATA[<p>There is a particular way a ClickOnce app fails after you add obfuscation that sends people down entirely the wrong rabbit hole: the app installs fine, then refuses to launch with a file validation or hash-mismatch error, and nothing in your code changed. The instinct is to suspect the obfuscator broke the assembly. It did not. ClickOnce is doing exactly what it is designed to do — refusing to run a file whose bytes do not match the hash it recorded — and the real bug is the <em>order</em> in which your build steps ran. Get the order right and obfuscation and ClickOnce coexist with no drama.</p>
<h2>Why ClickOnce is unusually sensitive</h2>
<p>Most deployment mechanisms copy files and run them. ClickOnce does more: its <strong>application manifest</strong> records, for every file in the deployment, a cryptographic hash and a size, and the ClickOnce runtime verifies each file against that record before launching. This is a tamper- and corruption-detection feature — it guarantees the user runs exactly what you published.</p>
<p>That guarantee is precisely what obfuscation collides with. Obfuscation <em>rewrites the bytes</em> of your assemblies — renaming, control-flow flattening, string encryption all change the file. If the manifest's hash for <code>MyApp.dll</code> was computed over the <strong>pre-obfuscation</strong> DLL, the post-obfuscation DLL you actually ship no longer matches, and ClickOnce halts with a validation error before a line of your code runs.</p>

  Obfuscate before manifests are hashed, not after
  
  
  Correct order:
  compile
  
  obfuscate
  
  signstrong-name/Authenticode
  
  mage: manifestshash final files
  Wrong order (app won't launch):
  compile
  
  mage: manifestshashes PRE-obf files
  
  obfuscate
  → hash mismatch at launch


<h2>The correct build order</h2>
<p>The rule is one sentence: <strong>every transformation of the assemblies must happen before the manifests are generated, and manifest generation must be last.</strong> Concretely:</p>
<ol>
<li><strong>Compile</strong> the project.</li>
<li><strong>Obfuscate</strong> the output assemblies.</li>
<li><strong>Re-apply the strong name</strong> if you delay-signed (obfuscation that re-emits the assembly must preserve or re-establish the strong name — see <a href="https://delta1labs.com/blog/re-sign-obfuscated-assembly-strong-name-authenticode/">re-signing obfuscated assemblies</a>).</li>
<li><strong>Authenticode-sign</strong> the binaries, if you sign them.</li>
<li><strong>Generate the application and deployment manifests</strong> with <code>mage</code>/<code>mageUI</code> or the MSBuild ClickOnce targets — this is where the hashes are computed, over the now-final files.</li>
<li><strong>Sign the manifests.</strong></li>
</ol>
<p>If you use Visual Studio's one-click Publish, the trap is that publish <em>does step 5 for you right after build</em>, before any post-build obfuscation hook has run. The fix is to take manifest generation out of the Publish step and run <code>mage</code> yourself after obfuscation (a small CI script), or run obfuscation as part of the build such that it completes before the ClickOnce targets execute.</p>
<h2>A minimal CI sketch</h2>
<pre><code class="language-bash"># 1-2: build then obfuscate the output
dotnet build -c Release -o publish
nebula protect publish/MyApp.dll   # (plus other assemblies)

# 3-4: re-sign as needed
sn -R publish/MyApp.dll keypair.snk           # if delay-signed
signtool sign /f cert.pfx publish/MyApp.dll    # Authenticode (optional)

# 5: generate manifests over the FINAL files (hashes computed now)
mage -New Application -Processor msil -ToFile publish/MyApp.exe.manifest \
     -Name "MyApp" -Version 1.2.0.0 -FromDirectory publish
mage -New Deployment -Processor msil -Install true \
     -ToFile MyApp.application -AppManifest publish/MyApp.exe.manifest \
     -providerUrl "https://downloads.example.com/MyApp/"

# 6: sign the manifests
mage -Sign MyApp.application -CertFile cert.pfx
</code></pre>
<p>Because the hashes in step 5 are taken over the obfuscated, signed assemblies, the ClickOnce runtime's verification passes and the app launches.</p>
<h2>Doing this with Nebula</h2>
<p><a href="https://delta1labs.com/nebula">Nebula</a> slots into the obfuscation step above, and because it runs as a build/CI stage (see <a href="https://delta1labs.com/blog/obfuscate-on-build-server-only/">obfuscating on the build server only</a> and the <a href="https://delta1labs.com/blog/obfuscate-dotnet-azure-devops/">Azure DevOps</a> / <a href="https://delta1labs.com/blog/obfuscate-dotnet-github-actions/">GitHub Actions</a> guides), it is straightforward to place it before ClickOnce manifest generation. It preserves strong-name signing so the re-sign step is clean, and its keep-rules handle any WinForms/WPF reflection your app does. The only ClickOnce-specific rule is the ordering — get that right and nothing else changes.</p>
<p>Start with the <a href="https://delta1labs.com/blog/free-dotnet-obfuscator/">free edition</a>, or see the <a href="https://delta1labs.com/pricing?product=nebula">plans</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Decompiling a Blazor WebAssembly App: Reading the .NET DLLs the Browser Downloaded]]></title><description><![CDATA[The first time someone opens the browser dev tools on a Blazor WebAssembly app and sees a _framework folder full of .wasm files, it looks reassuringly…]]></description><link>https://delta1labs.hashnode.dev/decompiling-a-blazor-webassembly-app-reading-the-net-dlls-the-browser-downloaded</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/decompiling-a-blazor-webassembly-app-reading-the-net-dlls-the-browser-downloaded</guid><category><![CDATA[decompilation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[Blazor]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 06:06:31 GMT</pubDate><content:encoded><![CDATA[<p>The first time someone opens the browser dev tools on a Blazor WebAssembly app and sees a <code>_framework</code> folder full of <code>.wasm</code> files, it looks reassuringly opaque — like the compiled WebAssembly blob you cannot read. It is not. Blazor WebAssembly does not compile your C# to WebAssembly; it ships a .NET runtime (as WASM) plus your <strong>ordinary .NET assemblies</strong>, and runs them in the browser. Those assemblies are downloaded to every visitor, metadata and IL intact, which means your client-side C# decompiles as cleanly as any DLL on your disk. Understanding how to read them is also the fastest way to understand what you must <em>not</em> put in them.</p>
<h2>What ships to the browser</h2>
<p>When a Blazor WebAssembly app loads, it fetches a manifest — <code>_framework/blazor.boot.json</code> — that lists every assembly the app needs, each with a filename and an integrity hash. The browser then downloads those files from <code>_framework/</code>. In current .NET they arrive as <strong>webcil</strong>: your PE assembly wrapped in a WebAssembly-compatible container. Webcil exists for a mundane reason — some proxies, CDNs, and corporate firewalls mangle or block raw <code>.dll</code> downloads — so the <code>.dll</code> is repackaged in a form that sails through as a <code>.wasm</code>-style asset. Crucially, <strong>webcil is a wrapper, not encryption</strong>: the original ECMA-335 metadata and IL are inside, byte-for-byte.</p>
<p>So the chain from "deployed app" to "your source" is short: <code>blazor.boot.json</code> names the assemblies, <code>_framework/</code> serves them, webcil holds the real PE, and a decompiler lifts the IL back to C#.</p>

  From a Blazor WASM deployment to decompiled C#
  
  
  blazor.boot.jsonlists assemblies
  
  _framework/*.wasmwebcil wrapper
  
  PE: metadata+IL(unchanged inside)
  
  Glass → C#readable source
  Webcil is packaging, not protection — the IL inside decompiles exactly like any .NET assembly.


<h2>Reading them in Glass</h2>
<p>The workflow is the same one you would use on any assembly, with one small front step for webcil:</p>
<ol>
<li>Open the running app's <code>_framework/blazor.boot.json</code> (dev tools → Network, or fetch it directly) and note the assemblies you care about — typically your app assembly and any of your own libraries, not the framework ones.</li>
<li>Download those files from <code>_framework/</code>.</li>
<li>Open them in <a href="https://delta1labs.com/glass">Glass</a>. Glass reads the .NET assembly inside the webcil container and reconstructs C# — your components, your services, your models — exactly as it does for a desktop DLL. (For older Blazor versions the files are plain <code>.dll</code> and open directly.)</li>
</ol>
<p>From there it is ordinary decompilation: navigate types, read method bodies, follow references — the same as <a href="https://delta1labs.com/blog/how-to-decompile-dotnet-dll-to-csharp/">decompiling any DLL to C#</a>.</p>
<h2>The real lesson: client-side is public</h2>
<p>This is less a decompilation tutorial than a security reminder with a demonstration attached. <strong>Blazor WebAssembly code runs on the user's machine, so it is as exposed as JavaScript.</strong> Anything you embed in it — an API key, a connection string, a licensing bypass, proprietary pricing logic — can be read back in minutes with the steps above. The defensive conclusions:</p>
<ul>
<li>Keep secrets and sensitive logic <strong>server-side</strong> (a backend API, or Blazor Server where the C# stays on the server and only DOM diffs cross the wire).</li>
<li>If you must ship sensitive logic to the client, <strong>obfuscate it</strong> — Blazor WASM assemblies can be renamed and string-encrypted like any assembly (see <a href="https://delta1labs.com/blog/protect-blazor-webassembly-from-decompilation/">protecting Blazor WebAssembly from decompilation</a>) — while remembering that client-side protection raises the bar, it does not make the code secret.</li>
</ul>
<p>Being able to read your own app the way an attacker would is the point: do it once, and you will know exactly what not to put in the browser.</p>
<h2>Doing this with Glass</h2>
<p><a href="https://delta1labs.com/glass">Glass</a> opens the assemblies behind a Blazor WebAssembly app — reading straight through the webcil wrapper to the IL and reconstructing C# — so you can audit precisely what your deployed client exposes. It is free, and it is the same tool you would use to <a href="https://delta1labs.com/blog/glass-understand-unfamiliar-dotnet-code/">understand any unfamiliar .NET code</a>.</p>
<p>Grab it from <a href="https://delta1labs.com/blog/free-dotnet-decompiler-glass/">the Glass page</a> and open your own app's <code>_framework</code> to see what ships.</p>
]]></content:encoded></item><item><title><![CDATA[Obfuscating Code That Generates Code: Reflection.Emit, DynamicMethod, and Expression Trees]]></title><description><![CDATA[Serializers, ORMs, DI containers, mappers, and rule engines all do the same trick under the hood: they write IL at runtime instead of calling your methods…]]></description><link>https://delta1labs.hashnode.dev/obfuscating-code-that-generates-code-reflectionemit-dynamicmethod-and-expression-trees</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/obfuscating-code-that-generates-code-reflectionemit-dynamicmethod-and-expression-trees</guid><category><![CDATA[obfuscation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[guide]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 05:59:58 GMT</pubDate><content:encoded><![CDATA[<p>Serializers, ORMs, DI containers, mappers, and rule engines all do the same trick under the hood: they write IL at runtime instead of calling your methods directly, because a generated, specialised method is far faster than reflection on every call. .NET gives you three main ways to do it — <code>DynamicMethod</code> + <code>ILGenerator</code>, <code>TypeBuilder</code> via <code>Reflection.Emit</code>, and <code>Expression.Compile</code> over LINQ expression trees. All three interact with obfuscation in a way the compiler cannot warn you about, because the breakage happens at runtime, in code that did not exist at build time. Here is the precise model of what is safe and what is not.</p>
<h2>The key distinction: the generator ships, the generated code does not</h2>
<p>Two different pieces of code are in play, and conflating them is where the confusion starts.</p>
<p>The <strong>generated code</strong> — the <code>DynamicMethod</code> body, the <code>TypeBuilder</code> type, the compiled expression delegate — is created in memory at runtime and never written to your assembly. The obfuscator never sees it, so it is neither protected nor harmed. (If protecting it mattered you could not, anyway; it is ephemeral.)</p>
<p>The <strong>generator</strong> — the method full of <code>il.Emit(...)</code> calls, or the expression-building code — <em>is</em> in your assembly. It is plain IL and gets renamed, control-flow-flattened, and string-encrypted like everything else. That is fine, with one catch: the generator usually references <em>your</em> types and members, and how it references them decides whether obfuscation breaks it.</p>

  The generator is obfuscated; the generated code is runtime-only
  
  
  In your assembly (obfuscated):
  
  Generator methodILGenerator.Emit / Expression tree
  
  emits at runtime
  Runtime-only (never on disk):
  
  DynamicMethod bodydiscarded at process exit
  Risk is only in the arrows the generator uses to name YOUR members:
  string lookup → breaks on rename · typeof/MethodInfo token → survives (rewritten with the rename).


<h2>Where it breaks: string-based member lookup</h2>
<p>A generator that resolves the members it emits calls to by <strong>string name</strong> is the one that breaks. Consider a <code>DynamicMethod</code> that calls a getter it found by name:</p>
<pre><code class="language-csharp">MethodInfo getter = typeof(Order).GetMethod("get_Total")!; // string lookup
var dm = new DynamicMethod("ReadTotal", typeof(decimal), new[] { typeof(Order) });
var il = dm.GetILGenerator();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Callvirt, getter);   // emits a call to whatever `getter` resolved to
il.Emit(OpCodes.Ret);
</code></pre>
<p>After obfuscation renames <code>Order.Total</code> (and its <code>get_Total</code> accessor), the string <code>"get_Total"</code> no longer matches anything, so <code>GetMethod</code> returns null and the next line throws <code>NullReferenceException</code> / <code>ArgumentNullException</code> — at runtime, with no compile-time hint. Same failure for <code>TypeBuilder</code> code that defines overrides by name, and for <code>Expression.Property(p, "Total")</code>.</p>
<p>The fix is one of two things. <strong>Keep the name</strong> of the specific members the generator looks up by string (a targeted <a href="https://delta1labs.com/blog/nebula-keep-list-rename-exclusions/">keep-rule</a>, not a blanket exclusion). Or, better, <strong>stop using strings</strong>: capture the member through something the obfuscator can rewrite.</p>
<h2>What survives: references the obfuscator can see</h2>
<p>When the generator captures a member as a <em>metadata reference</em> rather than a string, the obfuscator rewrites that reference as part of the rename, and everything keeps working:</p>
<pre><code class="language-csharp">// Survives renaming — MethodInfo captured from a delegate, not a string.
MethodInfo getter = ((Func&lt;Order, decimal&gt;)(o =&gt; o.Total)).Method; // won't help: that's a lambda's Method
// Better: read the MemberInfo out of an expression tree
Expression&lt;Func&lt;Order, decimal&gt;&gt; e = o =&gt; o.Total;
var prop = (PropertyInfo)((MemberExpression)e.Body).Member;          // real PropertyInfo, token-based
</code></pre>
<p>Compiled <strong>expression trees</strong> are the friendly case: <code>o =&gt; o.Total</code> carries a <code>PropertyInfo</code> reference that the obfuscator renames along with the property, so <code>Expression.Compile()</code> binds to the right member afterwards. The rule of thumb: <em>expression trees and <code>typeof(T).GetMethod(someMethodInfo-derived-reference)</code> are rewrite-safe; any <code>"string"</code> member name in a generator is not.</em></p>
<h2>A practical checklist</h2>
<ul>
<li>Audit your runtime-codegen sites for <strong>string literals naming your own members</strong>; those are the breakage points.</li>
<li>Prefer expression trees or delegate/<code>MethodInfo</code> capture over <code>GetMethod("Name")</code> where you can.</li>
<li>For the unavoidable string lookups, add <strong>targeted keep-rules</strong> for exactly those members — and remember their <em>bodies</em> are still obfuscated; only the name survives.</li>
<li>Test the obfuscated build's codegen paths explicitly, because the compiler and even a smoke test that skips the generated path will not catch a broken emit.</li>
</ul>
<h2>Doing this with Nebula</h2>
<p><a href="https://delta1labs.com/nebula">Nebula</a> renames, flattens, and string-encrypts your generators like any other code, and its <a href="https://delta1labs.com/blog/nebula-keep-list-rename-exclusions/">keep-rule</a> model is precise enough to preserve exactly the members your <code>Reflection.Emit</code> or expression-building code resolves by name, while still obfuscating their bodies. Pair it with the guidance in <a href="https://delta1labs.com/blog/obfuscation-reflection-serialization-pitfalls/">reflection and serialization pitfalls</a> and you can ship obfuscated code that generates code without a single runtime surprise.</p>
<p>Start with the <a href="https://delta1labs.com/blog/free-dotnet-obfuscator/">free edition</a>, or see the <a href="https://delta1labs.com/pricing?product=nebula">plans</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Keeping License Activation Across Desktop Auto-Updates: Velopack, Squirrel, MSIX, and ClickOnce]]></title><description><![CDATA[You added licensing, activation works, everyone is happy — and then you ship your first auto-update and the support tickets start: "it's asking me to…]]></description><link>https://delta1labs.hashnode.dev/keeping-license-activation-across-desktop-auto-updates-velopack-squirrel-msix-and-clickonce</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/keeping-license-activation-across-desktop-auto-updates-velopack-squirrel-msix-and-clickonce</guid><category><![CDATA[licensing]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[guide]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 05:58:04 GMT</pubDate><content:encoded><![CDATA[<p>You added licensing, activation works, everyone is happy — and then you ship your first auto-update and the support tickets start: "it's asking me to activate again." The cause is almost always the same, and it has nothing to do with your licensing logic. It is <em>where</em> you cached the activation. Modern .NET desktop updaters install each version into its own directory, and anything you wrote next to the executable goes with the old version. This post is the per-updater map of where to put the signed lease so activation survives.</p>
<h2>The root cause: versioned install directories</h2>
<p>A cached activation is a <strong>signed lease</strong> — a small, node-locked, RSA-signed blob the SDK writes after a successful activation so the app can <a href="https://delta1labs.com/blog/offline-license-validation-explained/">validate offline</a> on later launches without phoning home. The question is which directory it lands in.</p>
<p>Velopack and Squirrel do not update in place. They install version 1.0.0 to <code>…\YourApp\app-1.0.0\</code>, then install 1.1.0 to <code>…\YourApp\app-1.1.0\</code> and point a stub launcher at the new folder. If your lease was written next to the EXE — <code>AppDomain.CurrentDomain.BaseDirectory</code> or <code>Assembly.Location</code>'s folder — it was written into <code>app-1.0.0</code>, and the 1.1.0 process starts in a folder where that file does not exist. The lease is not <em>gone</em>; it is stranded in the previous version's directory, which the updater will eventually clean up.</p>

  Store the lease outside the versioned install folder
  
  
  
  app-1.0.0\lease(lost on update)
  update
  
  app-1.1.0\ (empty)→ re-activate prompt
  
  %LocalAppData%\YourApp\leasestable across every version → survives
  Same machine fingerprint before and after,
  so a reachable lease still validates offline.


<h2>Where to put the lease, per updater</h2>
<p>The rule is one line: <strong>store the lease in a stable, version-independent location, and point the SDK's lease-cache path at it.</strong> The specifics differ by updater.</p>
<p><strong>Velopack / Squirrel.</strong> Use a fixed per-user folder — <code>%LocalAppData%\YourApp</code> (the stable parent of the <code>app-*</code> folders) or <code>Roaming</code> if you want it to follow a roaming profile:</p>
<pre><code class="language-csharp">string dir = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "YourApp");
Directory.CreateDirectory(dir);
keyright.LeaseCachePath = Path.Combine(dir, "license.lease");
</code></pre>
<p><strong>MSIX.</strong> Packaged apps are redirected, but in your favour: <code>ApplicationData.Current.LocalFolder</code> persists <strong>across package updates</strong> because the package identity is stable. Write the lease there (not next to the EXE inside the package, which is read-only anyway):</p>
<pre><code class="language-csharp">// MSIX-packaged app
var folder = Windows.Storage.ApplicationData.Current.LocalFolder.Path;
keyright.LeaseCachePath = Path.Combine(folder, "license.lease");
</code></pre>
<p>This pairs with the packaging and machine-id details in <a href="https://delta1labs.com/blog/keyright-license-msix-packaged-windows-app/">licensing an MSIX-packaged app</a>.</p>
<p><strong>ClickOnce.</strong> Avoid the per-deployment data directory (it is isolated per version and migrated in ways you do not control). Use a fixed app-data folder you own, exactly like the Velopack case above.</p>
<p>In every case the SDK then reads that path on startup, verifies the lease signature against your embedded public key, and — because the hardware fingerprint is unchanged by a software update — validates offline with no server round-trip.</p>
<h2>Why this is safe, and the idempotency backstop</h2>
<p>Two facts make this robust. First, the lease is <strong>node-locked to hardware</strong>, and updating software does not touch the fingerprint, so a persisted lease is still valid for the machine after the update. Second, even in the edge case where a lease is genuinely lost (a user clears app-data, say), <strong>re-activating the same machine is idempotent</strong> in Keyright — the fingerprint matches the existing activation, so it does not burn a second seat (the mechanics are in <a href="https://delta1labs.com/blog/node-locking-seat-management/">node-locking and seat management</a>). So the worst case is a silent, seat-neutral re-activation, and the normal case is no prompt at all.</p>
<h2>Doing this with Keyright</h2>
<p><a href="https://delta1labs.com/keyright">Keyright</a> lets you set the lease-cache path explicitly, so you control exactly where the signed activation lives — a stable <code>%LocalAppData%</code> folder, the MSIX <code>LocalFolder</code>, or your own ClickOnce data directory — and it validates offline against the hardware fingerprint after every update. Combined with idempotent same-machine re-activation, your users activate once and never see the prompt again, no matter how often you ship. It is self-hosted, so the activation data and signing stay on infrastructure you control.</p>
<p>See the <a href="https://delta1labs.com/pricing?product=keyright">plans</a>, or start from <a href="https://delta1labs.com/blog/store-activated-license-client/">storing an activated license on the client</a> and move the path to a stable location.</p>
]]></content:encoded></item><item><title><![CDATA[Decompiling ReadyToRun (R2R) Assemblies: Why Precompiled Native Code Still Decompiles Cleanly]]></title><description><![CDATA[If you have ever been handed a "precompiled" .NET assembly and assumed a decompiler would choke on it, ReadyToRun is the happy surprise. R2R is how .NET…]]></description><link>https://delta1labs.hashnode.dev/decompiling-readytorun-r2r-assemblies-why-precompiled-native-code-still-decompiles-cleanly</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/decompiling-readytorun-r2r-assemblies-why-precompiled-native-code-still-decompiles-cleanly</guid><category><![CDATA[decompilation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[guide]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 05:55:43 GMT</pubDate><content:encoded><![CDATA[<p>If you have ever been handed a "precompiled" .NET assembly and assumed a decompiler would choke on it, ReadyToRun is the happy surprise. R2R is how .NET ships fast-starting code — the runtime libraries themselves are R2R, and <code>PublishReadyToRun=true</code> turns it on for your apps and container images — and it sounds like it should defeat decompilation, because it contains AOT-compiled native code. It does not. An R2R assembly decompiles to clean C# because the one thing R2R never throws away is the IL. Understanding why makes a whole category of "can I read this?" questions easy to answer.</p>
<h2>What an R2R image actually contains</h2>
<p>A normal .NET assembly is metadata + IL, JIT-compiled to native code on first execution. An R2R image is produced by <strong>crossgen2</strong>, which runs that JIT ahead of time and stores the resulting native code <em>in the assembly</em> — so the runtime can skip JITting on startup. Crucially, it stores the native code <strong>in addition to</strong>, not instead of, the original metadata and IL.</p>
<p>So the PE file contains three things that matter here: the ECMA-335 <strong>metadata tables</strong> (types, methods, signatures — untouched), the <strong>IL method bodies</strong> (also present), and a <strong>ReadyToRun section</strong> with precompiled native code plus the tables the runtime needs to use it. A <code>ReadyToRunHeader</code>, reachable through the CLI header's managed-native-header directory, ties it together and marks the image as R2R.</p>

  An R2R PE keeps metadata and IL alongside native code
  
  
  R2R assembly (one PE file):
  
  Metadata + IL(intact — what you read)
  
  ReadyToRun sectionprecompiled native code
  
  R2R headermarks the image
  
  
  Decompiler → reads IL → C#
  The native section is the runtime's fast path; the decompiler ignores it.


<h2>Why the decompiler ignores the native side</h2>
<p>A decompiler like <a href="https://delta1labs.com/glass">Glass</a> is an IL-to-C# reconstructor: it walks the metadata tables to recover types and signatures, then reads each method's <strong>IL body</strong> and lifts it back to C#. R2R's native code lives in a separate section with its own lookup tables, and the decompiler simply does not consult it — the IL is the authoritative, portable definition of every method, and it is still right there. So opening an R2R image is indistinguishable, from the decompiler's point of view, from opening a JIT-only assembly: same metadata, same IL, same C# out.</p>
<p>This is why you can decompile the .NET runtime libraries (which ship as R2R), a <code>PublishReadyToRun</code> app, or a crossgen'd container image without any special handling. The "it's precompiled" worry is about the <em>native</em> bytes, and those are not what a decompiler reads.</p>
<h2>Recognizing R2R, and the one real exception</h2>
<p>To confirm an assembly is R2R, look for the ReadyToRun header (tools label it "ReadyToRun" and often show an R2R version number), or simply know its origin: crossgen2 / <code>PublishReadyToRun</code>. The IL being present is the guarantee you can decompile it.</p>
<p>The exception that trips people up is <strong>NativeAOT</strong>, which sounds similar but is the opposite case. NativeAOT compiles the entire app to a self-contained native executable with <strong>no IL and no managed metadata to recover</strong> — there is nothing for an IL decompiler to read. The quick test: if a tool can load the file <em>as a .NET assembly</em> at all, it is managed (plain IL or R2R) and decompiles; if it is just a native binary, it is NativeAOT and you are looking at a native-disassembler job, not a decompilation one. (For how this affects <em>your</em> threat model as a vendor, see <a href="https://delta1labs.com/blog/how-to-protect-dotnet-code-from-decompilation/">protecting .NET code from decompilation</a>.)</p>
<h2>Doing this with Glass</h2>
<p><a href="https://delta1labs.com/glass">Glass</a> opens ReadyToRun assemblies transparently — it reads the intact IL and reconstructs C#, ignoring the native R2R section, so the runtime libraries, a crossgen'd service, or a <code>PublishReadyToRun</code> app all read like any other assembly. It is the same workflow as <a href="https://delta1labs.com/blog/how-to-decompile-dotnet-dll-to-csharp/">decompiling a DLL to C#</a>; R2R simply does not get in the way.</p>
<p>Glass is <a href="https://delta1labs.com/blog/free-dotnet-decompiler-glass/">free</a> — point it at an R2R image and see the IL come back as C#.</p>
]]></content:encoded></item><item><title><![CDATA[Per-Core Licensing for .NET Server Software: Counting Cores Correctly and Binding the Entitlement]]></title><description><![CDATA[Per-core licensing is how a lot of server software is sold: a price that scales with the CPU capacity the customer runs you on, because a database engine…]]></description><link>https://delta1labs.hashnode.dev/per-core-licensing-for-net-server-software-counting-cores-correctly-and-binding-the-entitlement</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/per-core-licensing-for-net-server-software-counting-cores-correctly-and-binding-the-entitlement</guid><category><![CDATA[licensing]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[guide]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 05:23:26 GMT</pubDate><content:encoded><![CDATA[<p>Per-core licensing is how a lot of server software is sold: a price that scales with the CPU capacity the customer runs you on, because a database engine or analytics service on a 64-core box delivers far more value than the same binary on a 4-core VM. The model is simple to state and surprisingly easy to get wrong, because the hard part is not the policy — it is answering, correctly and tamper-resistantly, the question "how many cores is this actually running on?" In .NET the obvious API gives you the wrong answer more often than the right one.</p>
<h2>The counting trap</h2>
<p>Reach for <code>Environment.ProcessorCount</code> and you will ship a licensing bug. It returns the number of <strong>logical</strong> processors available to the current process — which is not the capacity you are selling, in at least three ways:</p>
<ul>
<li>It counts <strong>hyperthreads</strong>, so an 8 physical-core chip typically reports 16.</li>
<li>It <strong>shrinks under affinity</strong>: if the process is pinned to a subset of CPUs (via affinity masks or a scheduler), it reports the subset, not the machine.</li>
<li>It <strong>ignores container quotas</strong>: in a Kubernetes pod limited to 2 CPUs on a 64-core node, it may still report a large number depending on runtime and version — you are not measuring the limit the orchestrator actually imposed.</li>
</ul>
<p>So before you write a single check, decide <em>which</em> number is your licensing unit: physical cores, logical processors, or the container CPU quota. Then measure that one on purpose.</p>

  Three different "core counts" for the same host
  
  
  Physical host: 32 physical cores / 64 logical processors
  Environment.ProcessorCount here can report 64 (logical) — rarely what you mean to license.
  
  Container: cgroup cpu.max = "200000 100000"
  → effective limit = 2 cores (what to license in k8s/Docker)
  
  Affinity-pinned process
  ProcessorCount reports the pinned subset, not the box


<h2>Measuring the right number</h2>
<p><strong>Logical processors</strong> (cheap, but least meaningful on its own):</p>
<pre><code class="language-csharp">int logical = Environment.ProcessorCount;
</code></pre>
<p><strong>Physical cores on Windows</strong> — sum <code>NumberOfCores</code> across sockets:</p>
<pre><code class="language-csharp">using System.Management; // Windows only
int physical = 0;
using var searcher = new ManagementObjectSearcher("SELECT NumberOfCores FROM Win32_Processor");
foreach (var mo in searcher.Get())
    physical += Convert.ToInt32(mo["NumberOfCores"]);
</code></pre>
<p><strong>Physical cores on Linux</strong> — count unique <code>(physical id, core id)</code> pairs:</p>
<pre><code class="language-csharp">var pairs = new HashSet&lt;(string socket, string core)&gt;();
string socket = "0", core = "0";
foreach (var line in File.ReadLines("/proc/cpuinfo"))
{
    if (line.StartsWith("physical id")) socket = line.Split(':')[1].Trim();
    else if (line.StartsWith("core id")) core = line.Split(':')[1].Trim();
    else if (line.Length == 0) pairs.Add((socket, core));
}
int physicalLinux = Math.Max(pairs.Count, 1);
</code></pre>
<p><strong>Container CPU quota</strong> — the number that actually matters in Kubernetes/Docker:</p>
<pre><code class="language-csharp">// cgroup v2: /sys/fs/cgroup/cpu.max  →  "&lt;quota&gt; &lt;period&gt;" or "max &lt;period&gt;"
static double? CgroupCpuLimit()
{
    const string v2 = "/sys/fs/cgroup/cpu.max";
    if (File.Exists(v2))
    {
        var parts = File.ReadAllText(v2).Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (parts[0] == "max") return null;                 // unlimited
        return double.Parse(parts[0]) / double.Parse(parts[1]);
    }
    // cgroup v1
    const string q = "/sys/fs/cgroup/cpu/cpu.cfs_quota_us";
    const string p = "/sys/fs/cgroup/cpu/cpu.cfs_period_us";
    if (File.Exists(q) &amp;&amp; File.Exists(p))
    {
        double quota = double.Parse(File.ReadAllText(q).Trim());
        if (quota &lt;= 0) return null;                        // unlimited
        return quota / double.Parse(File.ReadAllText(p).Trim());
    }
    return null;
}
</code></pre>
<p>Pick one of these as your <strong>measured capacity</strong> — most server products settle on physical cores for bare-metal/VM and the cgroup quota (rounded up) for containers.</p>
<h2>Binding the entitlement into the license</h2>
<p>Measuring is only half the job; the measured number has to be checked against something the customer cannot edit. That something is a signed entitlement. The license carries a <code>maxCores</code> value in its payload, signed by your private key, and the client verifies the signature before trusting it (the mechanics are in <a href="https://delta1labs.com/blog/license-entitlements-feature-flags/">licensing entitlements and feature flags</a> and <a href="https://delta1labs.com/blog/anatomy-signed-license-key/">the anatomy of a signed key</a>). Then, at startup:</p>
<pre><code class="language-csharp">var license = keyright.Validate();              // verifies signature against embedded public key
int licensedCores = license.GetInt("maxCores"); // trusted entitlement from the signed payload
int measured = MeasureLicensingCores();          // one of the numbers above, chosen per policy

if (measured &gt; licensedCores)
{
    // Fail closed — but loudly and actionably.
    throw new LicenseException(
        $"This license permits {licensedCores} cores; this host exposes {measured}. " +
        $"Reduce the CPU allocation or upgrade the license.");
}
</code></pre>
<p>Two design notes worth the ink. First, <strong>fail closed, but diagnose</strong>: an operator who accidentally scheduled your service on a bigger node should read exactly what happened — measured versus licensed — not watch the process die with a generic error. Second, consider a <strong>short grace</strong> or a one-time warning before enforcement bites, so a transient over-provision during a rollout does not take down production; this mirrors the grace thinking in <a href="https://delta1labs.com/blog/license-server-downtime-grace-windows/">license server downtime and grace windows</a>. Because the core count is read locally and the entitlement is verified locally, the whole check works offline and on every launch without a phone-home.</p>
<h2>Doing this with Keyright</h2>
<p><a href="https://delta1labs.com/keyright">Keyright</a> lets you put a <code>maxCores</code> (or <code>maxLogicalProcessors</code>, or a container-quota) entitlement into the signed license, and its .NET SDK verifies that payload against a public key you embed at build time — so the comparison between <em>licensed</em> and <em>measured</em> cores runs locally, offline, and fail-closed, with the diagnostic message you choose. You decide the counting policy; Keyright gives you the tamper-resistant number to check it against. It is self-hosted, so your signing key and your customers' license data stay on infrastructure you control.</p>
<p>See the <a href="https://delta1labs.com/pricing?product=keyright">plans</a>, or start from <a href="https://delta1labs.com/blog/add-software-licensing-to-dotnet-app/">adding licensing to a .NET app</a> and add the core-count entitlement to the model you already have.</p>
]]></content:encoded></item><item><title><![CDATA[Obfuscating Unity Game Assemblies: Mono, IL2CPP, and the Keep-Rules Serialization Demands]]></title><description><![CDATA[Ship a Unity game built on the Mono scripting backend and you have, in effect, published your source code. The Assembly-CSharp.dll sitting in your player's…]]></description><link>https://delta1labs.hashnode.dev/obfuscating-unity-game-assemblies-mono-il2cpp-and-the-keep-rules-serialization-demands</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/obfuscating-unity-game-assemblies-mono-il2cpp-and-the-keep-rules-serialization-demands</guid><category><![CDATA[obfuscation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[unity]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 05:23:12 GMT</pubDate><content:encoded><![CDATA[<p>Ship a Unity game built on the Mono scripting backend and you have, in effect, published your source code. The <code>Assembly-CSharp.dll</code> sitting in your player's <code>Managed</code> folder is ordinary .NET IL, and a decompiler turns it back into readable C# in seconds — your combat formulas, your anti-cheat, your in-app-purchase validation, your secret URLs, all right there. Obfuscation is the standard answer, but Unity is the one .NET environment where naively renaming everything <em>will</em> break your game. Unity leans harder on name-based serialization and reflection than almost any other framework, and the obfuscator has to be told what not to touch.</p>
<p>This post covers what each Unity backend actually exposes, the specific things Unity resolves by name, and the keep-rules that let you obfuscate aggressively without a single broken prefab.</p>
<h2>Two backends, two threat models</h2>
<p>Unity has two scripting backends, and they change what obfuscation even means.</p>
<p><strong>Mono</strong> compiles your scripts to managed assemblies that ship as real <code>.dll</code> files (<code>Assembly-CSharp.dll</code>, <code>Assembly-CSharp-firstpass.dll</code>, and any precompiled assemblies/asmdefs). These are standard CLI images: decompilable, and therefore fully obfuscatable. Renaming, control-flow flattening, and string encryption all apply to the shipped binary exactly as they would to any .NET app.</p>
<p><strong>IL2CPP</strong> takes that same managed IL and transpiles it to C++, which is then compiled to a native library for the target platform. There is no managed assembly to rename at runtime — but IL2CPP emits a <code>global-metadata.dat</code> file alongside the binary that preserves type names, method names, field names, and (critically) every string literal in clear text. Tools like Il2CppDumper read that file to reconstruct a near-complete picture of your code. So IL2CPP is <em>not</em> obfuscation; it is transpilation with a metadata sidecar. Protection there means transforming the managed assemblies <strong>before</strong> IL2CPP runs, so the names and strings that get baked into <code>global-metadata.dat</code> are already encrypted or renamed.</p>

  Where obfuscation fits in each Unity backend
  
  
  C# scripts → Roslyn → Assembly-CSharp.dll (managed IL)
  
  
  Nebula obfuscate (IL)
  
  
  Mono backend
  
  Ship obfuscated .dll in player
  
  
  IL2CPP backend
  
  il2cpp.exe → C++ → native
  
  
  native lib + 
  global-metadata.dat (names/strings)
  Obfuscating the IL first means the metadata IL2CPP bakes in is already renamed &amp; string-encrypted.


<h2>What Unity resolves by name — and will break if you rename it</h2>
<p>The editor and runtime both do a surprising amount of string- and name-based binding. Each of these is a place where blind renaming corrupts the game.</p>
<ul>
<li><strong>Serialized fields.</strong> Every <code>public</code> field and every <code>[SerializeField] private</code> field on a <code>MonoBehaviour</code> or <code>ScriptableObject</code> is saved into scenes, prefabs, and asset files keyed by its exact name. Rename the field and the saved value no longer loads.</li>
<li><strong>Script type references.</strong> A scene stores "this GameObject has a component of type <code>PlayerController</code>" by type (and assembly) identity. Renaming the class detaches every instance.</li>
<li><strong>UnityEvents.</strong> Inspector-wired <code>UnityEvent</code> listeners (your button <code>OnClick</code>, etc.) persist the <em>target method name</em> as a string. Rename the method and the button does nothing.</li>
<li><strong>String-dispatched calls.</strong> <code>SendMessage</code>, <code>BroadcastMessage</code>, <code>Invoke</code>, <code>InvokeRepeating</code>, and <code>AnimationEvent</code> all call methods by string name.</li>
<li><strong>Lifecycle &amp; attribute hooks.</strong> Unity finds <code>Awake</code>, <code>Start</code>, <code>Update</code>, <code>OnEnable</code>, etc. by name via reflection; it also scans for <code>[RuntimeInitializeOnLoadMethod]</code>, <code>[MenuItem]</code>, and similar. Magic methods are usually on a built-in keep list, but custom reflection is not.</li>
<li><strong>Addressables / asset bundles.</strong> Anything you load by type name or by string address at runtime.</li>
</ul>
<h2>A concrete example</h2>
<p>Here is a perfectly normal component that renaming would wreck in three different ways:</p>
<pre><code class="language-csharp">using UnityEngine;
using UnityEngine.Events;

public class TurretController : MonoBehaviour   // (1) type name is in every scene/prefab using it
{
    [SerializeField] private float fireRate = 0.5f;   // (2) field name is saved in the asset
    [SerializeField] private UnityEvent onFire;        // (3) persistent listeners store a method name

    // Called by name from an AnimationEvent on the fire clip:
    public void SpawnProjectile() { /* ... */ }         // (4) resolved by string, not a direct call
}
</code></pre>
<p>If Nebula renames <code>TurretController</code> → <code>a</code>, <code>fireRate</code> → <code>b</code>, and <code>SpawnProjectile</code> → <code>c</code>, then every turret prefab loses its fire-rate value, every scene loses its turret components, and the fire animation stops spawning projectiles — with no compiler error, because none of these bindings are checked at compile time.</p>
<h2>The keep-rules that fix it</h2>
<p>The goal is <em>not</em> "exclude MonoBehaviours from obfuscation." That would leave most of your code in the clear. The goal is to keep <strong>only the names Unity binds to</strong>, and obfuscate everything behind them — method bodies, private helpers, algorithms, and string literals.</p>
<p>Nebula expresses this through its keep-list / rename-exclusion rules (the same mechanism covered in <a href="https://delta1labs.com/blog/nebula-keep-list-rename-exclusions/">keep-lists and rename exclusions</a>). A practical Unity rule set keeps:</p>
<ul>
<li>the <strong>names of all types</strong> deriving from <code>UnityEngine.MonoBehaviour</code> and <code>UnityEngine.ScriptableObject</code>;</li>
<li>all <strong>serialized members</strong> on those types (public fields and <code>[SerializeField]</code> fields, and <code>[field: SerializeField]</code> auto-properties);</li>
<li>any <strong>method targeted by a UnityEvent or called via <code>SendMessage</code>/<code>Invoke</code>/<code>AnimationEvent</code></strong>.</li>
</ul>
<p>Expressed against members, that is "keep the name of any field marked <code>[SerializeField]</code> or public on a <code>UnityEngine.Object</code> subclass, and keep the type name of any <code>MonoBehaviour</code>/<code>ScriptableObject</code> subclass." Method <strong>bodies</strong> are never affected by name-keep rules, so control-flow flattening and <a href="https://delta1labs.com/blog/string-encryption-dotnet/">string encryption</a> still apply to <code>SpawnProjectile</code> even though its <em>name</em> is preserved — the logic is protected, only the identifier survives.</p>
<p>Everything else — your pure C# domain logic, networking, save-game format, anti-cheat, and all the string literals in them — gets the full treatment.</p>
<h2>Fitting it into the build</h2>
<p>Keep obfuscation out of the editor and off developer machines; run it on the artifact. For the <strong>Mono</strong> backend, add a post-build step (a <code>BuildPipeline</code> post-process or a CI stage) that obfuscates <code>Assembly-CSharp.dll</code> and your other game assemblies in the player's <code>Managed</code> directory, then repackages. For <strong>IL2CPP</strong>, obfuscate the managed assemblies before the IL2CPP conversion so the generated C++ and <code>global-metadata.dat</code> inherit renamed symbols and encrypted strings. Running protection in CI (see <a href="https://delta1labs.com/blog/obfuscate-on-build-server-only/">obfuscating on the build server only</a>) means your team keeps debugging clean, readable code and only shipped builds are hardened.</p>
<h2>Doing this with Nebula</h2>
<p><a href="https://delta1labs.com/nebula">Nebula</a> obfuscates the managed assemblies a Unity game is built from — renaming, control-flow flattening, and string encryption — with a keep-list model precise enough to preserve exactly the serialized fields, component type names, and event-target methods Unity binds by name, while still hardening every method body behind them. It runs as a post-build or CI step, so your editor stays fast and only the shipped player is protected. Nebula <strong>Pro</strong> adds <a href="https://delta1labs.com/blog/nebula-1-1-code-virtualization/">code virtualization</a> for the handful of routines that matter most — license checks, anti-cheat, purchase validation.</p>
<p>Start with the <a href="https://delta1labs.com/blog/free-dotnet-obfuscator/">free edition</a> to harden your first build, or see the <a href="https://delta1labs.com/pricing?product=nebula">plans</a> when you are ready for virtualization.</p>
]]></content:encoded></item><item><title><![CDATA[Decompiling F#-Compiled Assemblies in a C# Decompiler: Reading Unions, Records, and Modules]]></title><description><![CDATA[If you have ever opened an F#-built DLL in a decompiler expecting to see let and match and instead found classes with names like Tag and NewCircle, this…]]></description><link>https://delta1labs.hashnode.dev/decompiling-f-compiled-assemblies-in-a-c-decompiler-reading-unions-records-and-modules</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/decompiling-f-compiled-assemblies-in-a-c-decompiler-reading-unions-records-and-modules</guid><category><![CDATA[decompilation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[F#]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Wed, 11 Nov 2026 05:18:24 GMT</pubDate><content:encoded><![CDATA[<p>If you have ever opened an F#-built DLL in a decompiler expecting to see <code>let</code> and <code>match</code> and instead found classes with names like <code>Tag</code> and <code>NewCircle</code>, this post is for you. F# and C# are different languages, but they share one back end: both compile to ECMA-335 CLI metadata and IL. A decompiler reconstructs C# from that IL, so pointing a C# decompiler like <a href="https://delta1labs.com/glass">Glass</a> at an F# assembly gives you a faithful, readable — if slightly unusual — C# rendering of the F# compiler's output. Knowing how each F# construct is lowered turns that rendering from confusing into obvious.</p>
<h2>The fingerprint: FSharp.Core and CompilationMapping</h2>
<p>Two things immediately mark an assembly as F#-origin. First, a reference to <code>FSharp.Core.dll</code>, the runtime every F# program depends on. Second, the <code>[CompilationMapping]</code> attribute sprinkled across types and members. That attribute carries a <code>SourceConstructFlags</code> enum value — <code>Module</code>, <code>SumType</code>, <code>RecordType</code>, <code>UnionCase</code>, <code>Field</code>, <code>Value</code>, and friends — that records what the element <em>was</em> in F# source. A C# decompiler prints these attributes as-is, so they double as a legend: a type tagged <code>SourceConstructFlags.SumType</code> is a discriminated union, one tagged <code>Module</code> is an F# module, and so on.</p>

  F# source lowered to IL, then read back as C#
  
  
  
  F# source
  type Shape =
    | Circle of float
  type Point = {X;Y}
  
  
  IL + metadata
  classes + Tag
  [CompilationMapping]
  FSharp.Core ref
  
  
  Glass C# view
  abstract Shape
  + nested Circle
  Point : IEquatable
  Names are preserved, so the C# rendering is readable; the CompilationMapping flags say what each type originally was.


<h2>A worked example</h2>
<p>Take a small, idiomatic piece of F#:</p>
<pre><code class="language-fsharp">type Shape =
    | Circle of radius: float
    | Rectangle of width: float * height: float

type Point = { X: float; Y: float }

module Geometry =
    let area shape =
        match shape with
        | Circle r -&gt; System.Math.PI * r * r
        | Rectangle (w, h) -&gt; w * h
</code></pre>
<p>Decompiled to C#, Glass shows roughly this shape (trimmed for clarity):</p>
<pre><code class="language-csharp">[CompilationMapping(SourceConstructFlags.SumType)]
public abstract class Shape {
    public abstract int Tag { get; }               // discriminates the cases
    public static class Tags { public const int Circle = 0, Rectangle = 1; }

    [CompilationMapping(SourceConstructFlags.UnionCase, 0)]
    public class Circle : Shape {
        public readonly double radius;
        public override int Tag =&gt; 0;
    }
    [CompilationMapping(SourceConstructFlags.UnionCase, 1)]
    public class Rectangle : Shape {
        public readonly double width, height;
        public override int Tag =&gt; 1;
    }
}

[CompilationMapping(SourceConstructFlags.RecordType)]
public sealed class Point : IEquatable&lt;Point&gt;, IStructuralEquatable, IComparable&lt;Point&gt;, IComparable {
    public double X { get; }
    public double Y { get; }
    // structural Equals / GetHashCode / CompareTo generated by the F# compiler
}

[CompilationMapping(SourceConstructFlags.Module)]
public static class Geometry {
    public static double area(Shape shape) {
        switch (shape.Tag) {
            case 0: { double r = ((Shape.Circle)shape).radius; return Math.PI * r * r; }
            default: { var rc = (Shape.Rectangle)shape; return rc.width * rc.height; }
        }
    }
}
</code></pre>
<p>Every F# construct is now legible as a C# pattern: the <strong>union</strong> became an abstract base with nested subclasses and an integer <code>Tag</code>; the <strong>record</strong> became a sealed class carrying the structural <code>Equals</code>/<code>GetHashCode</code>/<code>CompareTo</code> the F# compiler synthesises; the <strong>module</strong> became a static class; the <strong><code>match</code></strong> became a <code>switch</code> on <code>Tag</code>. Pattern reading, once you know the mapping, is mechanical.</p>
<h2>The rest of the vocabulary</h2>
<ul>
<li><strong>Functions and currying.</strong> A let-bound function on a module is a static method. A <em>curried</em> or partially-applied function surfaces as an <code>FSharpFunc&lt;,&gt;</code> value, often with a compiler-generated closure class holding the captured arguments. Higher-order code reads as <code>FSharpFunc</code> invocations rather than lambdas.</li>
<li><strong>Tuples.</strong> F# tuples become <code>System.Tuple&lt;...&gt;</code> (reference tuples) or <code>System.ValueTuple&lt;...&gt;</code> for struct tuples; element names are not preserved at the IL level.</li>
<li><strong>Options.</strong> <code>'T option</code> is <code>FSharpOption&lt;T&gt;</code> with <code>Some</code>/<code>None</code>; <code>None</code> is the null reference, which is why you sometimes see null checks where the F# said <code>None</code>.</li>
<li><strong>Entry point.</strong> <code>[&lt;EntryPoint&gt;]</code> becomes a <code>static int Main(string[])</code> tagged with <code>[CompilationMapping(SourceConstructFlags.Value)]</code> plus <code>[EntryPoint]</code>-style marking.</li>
<li><strong>Nullary union cases</strong> (like a <code>None</code>-style tag) are usually exposed as cached singleton instances rather than fresh allocations.</li>
</ul>
<h2>Reading an unfamiliar F# library</h2>
<p>This is where a C# decompiler earns its keep: you have a third-party F# assembly with no source, and you need to understand its public surface. In Glass, open the assembly, and the <code>[CompilationMapping]</code> flags immediately tell you which types are unions versus records versus plain classes. Expand a union to see its cases and payloads; expand a module's static class to read the functions; follow a <code>switch (x.Tag)</code> to see how a function branches on a union. Because F# does not strip or mangle names, the identifiers you see are the ones the author wrote — the only translation you are doing in your head is F#-construct → C#-shape, and the <code>CompilationMapping</code> legend hands you that for free.</p>
<p>The honest limit: you are reading the <em>lowered</em> form. Computation expressions have been desugared into <code>Bind</code>/<code>Return</code> calls, <code>inline</code> functions may have vanished into their call sites, and active patterns appear as ordinary method calls returning options. You can always recover the behaviour; you cannot always recover the elegant F# that produced it.</p>
<h2>Doing this with Glass</h2>
<p><a href="https://delta1labs.com/glass">Glass</a> is a free, VS-native .NET decompiler, and because it reconstructs C# from CLI metadata it reads F#-compiled assemblies as readily as C# ones — showing the <code>[CompilationMapping]</code> attributes, the union class hierarchies, and the record equality members so you can navigate an unfamiliar F# library the same way you would any .NET assembly. It is the same workflow as <a href="https://delta1labs.com/blog/decompile-csharp-records/">decompiling C# records</a> or <a href="https://delta1labs.com/blog/decompile-yield-return-iterator-state-machine/">iterator state machines</a>; F# just adds one legend to learn.</p>
<p>Grab it from <a href="https://delta1labs.com/blog/free-dotnet-decompiler-glass/">the Glass page</a> — it is free — and open your first F# assembly.</p>
]]></content:encoded></item><item><title><![CDATA[Licensing a .NET app on Linux and macOS: no registry, no AppData — where the Keyright lease lives, what the fingerprint anchors to, and the systemd]]></title><description><![CDATA[The Keyright SDK does not care which operating system it is on. Keyright.NET targets netstandard2.0 and net8.0, Validate() is pure computation over bytes…]]></description><link>https://delta1labs.hashnode.dev/licensing-a-net-app-on-linux-and-macos-no-registry-no-appdata-where-the-keyright-lease-lives-what-th</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/licensing-a-net-app-on-linux-and-macos-no-registry-no-appdata-where-the-keyright-lease-lives-what-th</guid><category><![CDATA[keyright]]></category><category><![CDATA[licensing]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[#DeepDive  ]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Tue, 10 Nov 2026 17:08:18 GMT</pubDate><content:encoded><![CDATA[<p>The Keyright SDK does not care which operating system it is on. <code>Keyright.NET</code> targets <code>netstandard2.0</code> and <code>net8.0</code>, <code>Validate()</code> is pure computation over bytes, and <code>ActivateAsync</code> is an HTTPS call. Deploy a licensed worker on Ubuntu or ship a Mac build of your desktop app and the same code runs. That is the good news, and it is also why the three platform differences that <em>do</em> matter tend to surface as a support ticket rather than a compile error.</p>
<p>A licensing SDK depends on three things the platform provides: somewhere per-user to keep a small signed file, a stable identity for the machine, and — less obviously — an application that gets relaunched often enough to refresh what it cached. On Windows those are <code>%LocalAppData%</code>, the registry <code>MachineGuid</code>, and the fact that desktop apps start every morning. Off Windows all three are different, and a <a href="https://delta1labs.com/products/keyright/">Keyright</a> integration that was tested on a developer's Windows laptop can quietly do the wrong thing on the Linux box it is deployed to. This post is the Linux and macOS version of the story: where the files go, what the fingerprint is made of, how to give a systemd service a home, and the refresh loop a process that never restarts needs.</p>
<h2>Where the files go when there is no AppData</h2>
<p>A Keyright client keeps up to three files per product, all rooted on <code>Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)</code>:</p>
<pre><code class="language-text">{LocalApplicationData}/Keyright/{product}/lease.json     signed activation lease (cached after ActivateAsync)
{LocalApplicationData}/Keyright/{product}/state.json     first-seen / last-seen timestamps (trial clock, clock-tamper)
{LocalApplicationData}/Keyright/{product}/license.json   default location for an offline license file
</code></pre>
<p>On Unix, .NET resolves <code>LocalApplicationData</code> the XDG way: <code>$XDG_DATA_HOME</code> if it is set, otherwise <code>$HOME/.local/share</code>. And it does that on <strong>macOS too</strong> — not <code>~/Library/Application Support</code>, which is where a Mac user would look. So for a user <code>dana</code> the lease sits at:</p>
<pre><code class="language-text">Linux   /home/dana/.local/share/Keyright/acme-agent/lease.json
macOS   /Users/dana/.local/share/Keyright/acme-studio/lease.json
</code></pre>
<p>Three things follow from "it is under <code>$HOME</code>", and they are the whole first half of this post.</p>
<p><strong>It is per user.</strong> Two users on the same machine each have their own cache and each activate once. That is not two seats: both activations fingerprint the same machine and send the same <code>machineId</code>, so the service binds one seat and refreshes it twice — exactly the shared-PC behaviour described in the <a href="https://delta1labs.com/blog/keyright-license-msix-packaged-windows-app/">MSIX post</a>. The cache is per user; the seat was always per machine.</p>
<p><strong>It depends on <code>HOME</code> being real and writable.</strong> A systemd unit with <code>ProtectHome=yes</code> sees an empty, read-only <code>/home</code>. A <code>DynamicUser=yes</code> unit runs as a transient UID whose home is <code>/</code>. A hand-made service account is often created with <code>--home /nonexistent</code> or <code>--home /var/empty</code>. In all of those, <code>~/.local/share/Keyright</code> cannot be created. The SDK does not throw: the lease cache is deliberately best-effort, so a failed write is swallowed, <code>ActivateAsync</code> returns a perfectly valid <code>LicenseInfo</code>, and the service runs licensed — until the next restart, when there is no cached lease and it activates again. Online, that is invisible; it just costs a round-trip. Offline, it is <code>NoLicense</code> and a fail-closed service that worked yesterday.</p>
<p><strong>A sandboxed Mac app has a different <code>HOME</code>.</strong> Under App Sandbox, <code>HOME</code> is redirected to the app's container, so <code>~/.local/share/Keyright/acme-studio/lease.json</code> lands at <code>~/Library/Containers/com.acme.studio/Data/.local/share/Keyright/acme-studio/lease.json</code>. Everything works — the sandbox is writable, it survives updates, and it is deleted when the user removes the app and its container. It is the macOS counterpart of MSIX write virtualization: the path the SDK logs is not the path the bytes are in. Make it explicit with <code>LeaseCachePath</code> and friends if a support engineer will ever need the file.</p>
<h2>What the fingerprint anchors to</h2>
<p>The machine fingerprint the SDK sends on activation is a set of salted hashes, <code>KRM1:anchor:soft1:soft2</code>, built from an ordered list of components where index 0 is the <strong>anchor</strong> that must always match and the rest are soft signals that <code>NodeLockTolerance</code> (default <code>1</code>) lets drift one at a time. On Windows the anchor is <code>HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid</code>. Off Windows there is no registry, and the SDK reads the platform's equivalent:</p>
<figure class="my-8">One fingerprint, three anchor sourcesWindows · anchorHKLM\...\Cryptography\MachineGuidregistry, read-onlyLinux · anchor/etc/machine-id/var/lib/dbus/machine-id32 hex chars, mode 0444macOS · anchorIOPlatformUUID(IOPlatformExpertDevice)ioreg, survives upgradesnone readable · fallback~/.local/share/Keyright/machine.id  (random, persisted)per user, not per machinesoft · machine-nameEnvironment.MachineName (hostname)soft · osPlatform:Version.Major  e.g. Unix:6anchor must match;NodeLockTolerance = 1 letsone soft component changesalted SHA-256 per component, on the machine · only hashes leave itKRM1:7Q3K…(anchor):M2PX…(name):A9F4…(os)      DisplayId  KR-XXXX-XXXX-XXXXHostname changed? Kernel went 5 → 6? One soft mismatch, still this machine. New /etc/machine-id? A new machine.</figure>

<ul>
<li><strong>Linux:</strong> <code>/etc/machine-id</code>, the 32-hex-character id systemd generates on first boot, with <code>/var/lib/dbus/machine-id</code> as a fallback for systems that only have the D-Bus one. It is world-readable (mode <code>0444</code>), so a service running as an unprivileged user can read it without any capability — which is why <code>DynamicUser=</code> units fingerprint correctly.</li>
<li><strong>macOS:</strong> the <code>IOPlatformUUID</code> of the <code>IOPlatformExpertDevice</code> entry in the I/O registry, read via <code>ioreg</code>. It is the per-machine id Apple's own services use, and it survives OS upgrades, hostname changes and a user wiping <code>~/</code>.</li>
<li><strong>Nothing readable:</strong> the SDK generates a random id, persists it at <code>~/.local/share/Keyright/machine.id</code>, and anchors on that. On Windows and on any normal Linux or Mac install this file never exists; it is the last resort for a container with no <code>machine-id</code>, or a locked-down sandbox that cannot spawn <code>ioreg</code>. Note what it costs: the fallback is <strong>per user</strong>, so two users on such a machine would be two machines and two seats.</li>
</ul>
<p>The two soft components are the host name and <code>Environment.OSVersion.Platform</code> plus the major version — <code>Unix:6</code> on a Linux 6.x kernel, the macOS major on a Mac. The tolerance is what keeps those honest: rename the host and the anchor still matches, one soft component differs, the lease validates. Upgrade a distribution from a 5.x to a 6.x kernel, same thing. Do both in the same week and you have two soft mismatches, which exceeds the default tolerance — the lease reports <code>MachineMismatch</code> and the next <code>ActivateAsync</code> re-binds, idempotently, because the service matches on the anchor. The design is explained in the <a href="https://delta1labs.com/blog/machine-fingerprint-device-binding/">fingerprint post</a>; the Linux-specific point is just that your "hardware" anchor is a file.</p>
<h2>The two machine-id traps</h2>
<p>Because the anchor is a file, it can be copied, and it can be missing. Each is a different failure.</p>
<p><strong>Copied: the golden image.</strong> Build a VM image, install your agent, snapshot it, provision forty hosts from the snapshot. Unless something cleared <code>/etc/machine-id</code> before the snapshot was sealed, all forty boot with the same id, hash to the same anchor, and present the same <code>machineId</code>. Keyright does what it does for a machine it has already seen: the first host activates and binds a seat, the other thirty-nine refresh it. The dashboard shows one device. You sold one seat and are running forty — this is the trap that fails <em>open</em>, which is the kind you do not get a ticket about. The fix lives in the image, not the SDK: <code>truncate -s0 /etc/machine-id</code> before sealing (an empty file tells systemd to generate a fresh id on first boot), or run <code>systemd-machine-id-setup</code> from first-boot provisioning. cloud-init does this; templates built by hand often do not. The <a href="https://delta1labs.com/blog/detect-cloned-vm-duplicate-activation/">cloned-VM post</a> covers detecting the duplicates from the service side when you cannot control the images. If you <em>want</em> many hosts to count as one deployment, say so with a custom <code>IMachineComponents</code>, as in the <a href="https://delta1labs.com/blog/license-containerized-dotnet-services/">containers post</a> — a decision in code, not an accident in a template.</p>
<p><strong>Missing: the container.</strong> Minimal container images ship an empty <code>/etc/machine-id</code> or none at all. The SDK reads an empty string, treats it as unreadable, and falls through to the persisted random fallback — which lives in the container's writable layer and is gone with the container. Every restart is a new machine and a new seat. That is the same problem the containers post solves by overriding the identity source, and nothing on this page changes it; what this page adds is <em>why</em> the default behaves that way: the file is empty.</p>
<h2>A systemd service: giving the SDK a home</h2>
<p>Now the half of the post that matters for most Linux deployments: a licensed .NET worker running under systemd. A unit written with modern hardening looks like this, and every line that matters to licensing is annotated:</p>
<pre><code class="language-ini"># /etc/systemd/system/acme-agent.service
[Unit]
Description=Acme Agent
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/opt/acme/acme-agent
User=acme
Group=acme
Restart=on-failure

# Hardening: the filesystem is read-only and /home is empty. $HOME/.local/share is not writable.
ProtectSystem=strict
ProtectHome=yes

# The one directory this service may write. systemd creates /var/lib/acme-agent, chowns it to acme,
# and exports it as $STATE_DIRECTORY. This is where the lease, local state and offline license go.
StateDirectory=acme-agent
StateDirectoryMode=0700

# Site/OEM deployments: hand the service a signed license file nobody else on the host can read.
# Exposed at $CREDENTIALS_DIRECTORY/license.json, readable by this service only, not in the environment.
LoadCredential=license.json:/etc/acme/license.json

# Key-activated deployments instead: the license key, same mechanism. One or the other.
# LoadCredential=license-key:/etc/acme/license.key

[Install]
WantedBy=multi-user.target
</code></pre>
<p><code>StateDirectory=</code> is the answer to the <code>HOME</code> problem. It gives the service a directory that exists, is owned by the service user, survives restarts and upgrades, and is exempt from <code>ProtectSystem=strict</code>. <code>StateDirectoryMode=0700</code> keeps other users on the host out of it. <code>LoadCredential=</code> is the answer to "where does the license <em>come from</em> on a machine with no human": systemd copies the file into a per-service tmpfs and tells the process where with <code>CREDENTIALS_DIRECTORY</code>. Unlike an <code>Environment=</code> line, the content never appears in <code>systemctl show</code>, <code>/proc/&lt;pid&gt;/environ</code>, or a child process.</p>
<p>The client reads both:</p>
<pre><code class="language-csharp">using Keyright.Client;

static KeyrightClient CreateLicenseClient()
{
    var options = new KeyrightOptions
    {
        Product         = "acme-agent",
        PublicKeyBase64 = AcmeKeys.LicensePublicKey,       // public key only; safe to ship in the binary
        ServiceUrl      = "https://licensing.acme.example",
    };

    // systemd StateDirectory= — may list several directories, colon-separated; the first is ours.
    string? state = Environment.GetEnvironmentVariable("STATE_DIRECTORY")?.Split(':')[0];
    if (!string.IsNullOrEmpty(state))
    {
        string root = Path.Combine(state, "keyright");
        options.LeaseCachePath     = Path.Combine(root, "lease.json");
        options.LocalStatePath     = Path.Combine(root, "state.json");
        options.DefaultLicensePath = Path.Combine(root, "license.json");
    }

    // systemd LoadCredential= — a site/OEM license file. Explicit path = authoritative, no fall-through.
    string? creds = Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY");
    if (creds is not null &amp;&amp; File.Exists(Path.Combine(creds, "license.json")))
        options.LicenseFilePath = Path.Combine(creds, "license.json");

    return KeyrightClient.Initialize(options);
}
</code></pre>
<p>Outside systemd — a developer running the binary from a shell — neither variable is set, the SDK defaults to <code>~/.local/share</code>, and the same code is right. There is no "am I a service" flag to get wrong.</p>
<p>The <code>LicenseFilePath</code> branch is the same explicit-source rule as everywhere else in the SDK: when it is set, the ambient sources (<code>KEYRIGHT_LICENSE</code>, the config path, the default path, the cached lease) are not consulted, and a missing or invalid file is <code>NoLicense</code> or <code>SignatureInvalid</code> rather than a fall-through. For a deployment where licensing is an operator decision, that is what you want. Note that the license file is <em>not</em> a secret in the way a password is — it is a signed document bound to this machine's fingerprint, useless anywhere else — but it does carry the license key and the licensee's name, and <code>LoadCredential=</code> is simply the cleanest way to put a file in front of exactly one process on a Linux host.</p>
<p>The macOS daemon story is the same with different names. A <code>LaunchDaemon</code> running as a dedicated <code>_acme</code> user has a home of <code>/var/empty</code>, which is read-only; <code>HOME</code> for a root daemon is <code>/var/root</code>. Put the three paths under <code>/Library/Application Support/Acme/keyright/</code> with mode <code>0700</code>, owned by the daemon's user, and the SDK is happy. launchd has no <code>LoadCredential=</code>; a <code>0600</code> file under <code>/Library/Application Support/Acme/</code> referenced via <code>LicenseFilePath</code> is the equivalent.</p>
<h2>The daemon that never relaunches</h2>
<p>Here is the difference that has nothing to do with file paths and bites hardest.</p>
<p>An activation lease is a short-lived signed token — 14 days by default — and the SDK's recommended refresh pattern is <em>call <code>ActivateAsync</code> at startup</em>. For a desktop app that is a complete solution: the user launches it most days, each launch refreshes the lease when online, and a laptop that stays offline for two weeks is the edge case the window was sized for. A service inverts the frequencies. It launches once, after a deploy, and then runs for months. Nothing calls <code>ActivateAsync</code> again, the lease reaches <code>leaseExpiresUtc</code>, and the next <code>Validate()</code> — in a request handler, on day 15 — returns <code>Expired</code> with the message "reconnect to re-validate". The process is up, the network is fine, the license is paid for, and the service has failed closed to Free because nobody relaunched it.</p>
<figure class="my-8">Who refreshes the 14-day lease?day 1day 8day 15day 20desktop app● = launch → ActivateAsync → lease extended 14 days from here. Never lapses.daemon, no loopExpired → Freeone launch at deploy. Process still running on day 15; nothing refreshed the lease.daemon + loopActivateAsync every 6 h: idempotent, same seat. Amber = service unreachable; cached lease bridges it.The lease is always within hours of fresh, so a 14-day window is 14 days of tolerated outage, not of uptime.</figure>

<p>The fix is a refresh loop inside the service. <code>ActivateAsync</code> is safe to call repeatedly: re-activating a machine the service has already seen is idempotent — same seat, fresh lease — and if the licensing service is unreachable the SDK falls back to the still-valid cached lease rather than discarding it, so the loop can only ever extend the window, never shorten it.</p>
<pre><code class="language-csharp">using Keyright.Client;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

/// &lt;summary&gt;Keeps the activation lease fresh in a process that may run for months without restarting.&lt;/summary&gt;
public sealed class LeaseRefresher(KeyrightClient license, ILogger&lt;LeaseRefresher&gt; log) : BackgroundService
{
    private static readonly TimeSpan Interval = TimeSpan.FromHours(6);

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        // Key-activated deployment: the key arrives as a systemd credential, never in the environment.
        string? creds = Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY");
        string? keyPath = creds is null ? null : Path.Combine(creds, "license-key");
        if (keyPath is null || !File.Exists(keyPath))
        {
            log.LogInformation("No license key credential; running from a license file, nothing to refresh.");
            return;                                   // site/OEM file deployments: the file IS the license
        }
        string key = (await File.ReadAllTextAsync(keyPath, ct)).Trim();

        using var timer = new PeriodicTimer(Interval);
        do
        {
            LicenseInfo info = await license.ActivateAsync(key, ct);   // idempotent: same machine, same seat, new lease
            if (info.IsValid)
                log.LogInformation("License lease refreshed; {Status}, {DaysRemaining} days remaining.", info.Status, info.DaysRemaining);
            else
                log.LogWarning("License refresh did not yield a valid lease: {Status} — {Message}", info.Status, info.Message);
        }
        while (await timer.WaitForNextTickAsync(ct));
    }
}
</code></pre>
<p>Register it with <code>services.AddHostedService&lt;LeaseRefresher&gt;()</code> next to the singleton client. Six hours is a reasonable interval: the lease is then always within six hours of fresh, so the 14-day window becomes fourteen days of <em>tolerated outage</em> of your licensing service rather than fourteen days of uptime. The first iteration runs immediately, which doubles as the startup activation. Log the status, never the key — the <a href="https://delta1labs.com/blog/keyright-structured-logging-license-events-redact-key/">structured-logging post</a> explains why <code>info.Status</code> and <code>info.DaysRemaining</code> are the right fields.</p>
<p>Two things the loop does <em>not</em> do, on purpose. It does not restart the process when the status goes bad: <code>Validate()</code> already fails closed on its own, and the <a href="https://delta1labs.com/blog/keyright-license-state-aspnet-core-health-check/">health-check post</a> shows how to surface a degraded license to the orchestrator without a crash loop. And it does not run for file-based deployments: a signed site license file has its own expiry and nothing to refresh, so the loop checks for the key credential and steps out.</p>
<h2>The file mode the lease deserves</h2>
<p>On Windows the lease lives under a profile only its user can read. On Linux, <code>File.WriteAllText</code> creates files at <code>0666 &amp; ~umask</code>, which with the usual <code>022</code> is <code>0644</code> — readable by every account on the host. The lease is not dangerous to read: it is signed, so editing it yields <code>SignatureInvalid</code>, and it is bound to this machine's fingerprint, so copying it elsewhere yields <code>MachineMismatch</code>. But it <em>does</em> carry the license key, and a key lets its holder attempt an activation against your seat limit from any machine. On a single-user laptop that is nothing; on a shared build host or a jump box it is a key leak via <code>ls</code>.</p>
<p><code>StateDirectoryMode=0700</code> already closes this for a systemd service. For everything else, tighten the directory before the SDK writes into it — <code>KeyrightClient.Initialize</code> does not create the directories, the first write does, so creating them yourself first wins:</p>
<pre><code class="language-csharp">using System.IO;

static void SecureLicenseDirectory(string leaseCachePath)
{
    if (OperatingSystem.IsWindows()) return;                  // ACLs on the profile already do this
    string dir = Path.GetDirectoryName(leaseCachePath)!;
    Directory.CreateDirectory(dir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);   // 0700
    if (File.Exists(leaseCachePath))
        File.SetUnixFileMode(leaseCachePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);                   // 0600
}
</code></pre>
<p><code>Directory.CreateDirectory(path, UnixFileMode)</code> and <code>File.SetUnixFileMode</code> are .NET 7+ APIs; on older targets a <code>chmod</code> P/Invoke does the same. A <code>0700</code> directory is enough on its own — a <code>0644</code> file inside a directory nobody else can traverse is unreadable — so the file-mode call is belt and braces for a lease that already existed before you tightened the directory.</p>
<h2>Print what the gate will see</h2>
<p>Every post in this series ends up recommending the same diagnostic, because it is the thing that turns a ticket into one line: a <code>--license-info</code> flag that prints the paths and the fingerprint <em>from the code path the gate uses</em>, so what it shows is what the SDK does.</p>
<pre><code class="language-csharp">using Keyright.Client;
using Keyright.NodeLock;

// acme-agent --license-info
static void PrintLicenseInfo(KeyrightClient license)
{
    var fp = MachineFingerprint.Current();
    LicenseInfo info = license.Validate(out LicenseSourceKind source);

    Console.WriteLine($"Machine id    : {fp.ToBoundString()}");              // what the dashboard's Devices panel lists
    Console.WriteLine($"Display id    : {fp.DisplayId}");                    // short form for a support conversation
    Console.WriteLine($"Anchor source : {AnchorSource()}");
    Console.WriteLine($"Lease cache   : {license.LeaseCachePath}  (exists: {File.Exists(license.LeaseCachePath)})");
    Console.WriteLine($"Local state   : {license.LocalStatePath}");
    Console.WriteLine($"License file  : {license.DefaultPath}");
    Console.WriteLine($"Status        : {info.Status} via {source}  — {info.Message}");
}

static string AnchorSource()
{
    if (OperatingSystem.IsWindows()) return "registry MachineGuid";
    if (OperatingSystem.IsMacOS())   return "IOPlatformUUID";
    foreach (var p in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" })
        if (File.Exists(p) &amp;&amp; File.ReadAllText(p).Trim().Length &gt; 0) return p;
    return "persisted fallback (~/.local/share/Keyright/machine.id) — per user, not per machine";
}
</code></pre>
<p>The <code>Anchor source</code> line is the one that catches the golden-image and container traps before they reach production: <code>/etc/machine-id</code> on a real host, the fallback in a bare container. The <code>via {source}</code> suffix — <code>Lease</code>, <code>ExplicitFile</code>, <code>EnvironmentVariable</code>, <code>DefaultPath</code> — tells you which of the sources won, which is the first question when a service that should be reading a credential file is somehow activating online instead.</p>
<h2>Verify it</h2>
<p>The whole argument is testable on one host in a few minutes. The important trick is in step 2: run the diagnostic <em>inside the unit's environment</em>, not from your own shell as the service user — <code>sudo -u acme</code> gives you the user but not <code>STATE_DIRECTORY</code> or the credentials, and the paths it prints are the wrong ones.</p>
<pre><code class="language-bash"># 1. The anchor. Non-empty, and different on every host built from the same image.
cat /etc/machine-id
#    → e.g. 8c3f1a2b...   (empty? the image was not cleared: truncate -s0 /etc/machine-id and reboot)

# 2. Activate once, as the unit would. systemd-run builds a transient unit with the same properties.
sudo systemd-run --pty --wait -p User=acme -p StateDirectory=acme-agent \
    -p LoadCredential=license-key:/etc/acme/license.key \
    /opt/acme/acme-agent license activate
sudo systemd-run --pty --wait -p User=acme -p StateDirectory=acme-agent \
    /opt/acme/acme-agent --license-info
#    → Lease cache : /var/lib/acme-agent/keyright/lease.json (exists: True), Status: Valid via Lease
#    Dashboard: the license shows 1 device, the KRM1:… machine id from the line above.

# 3. Owner and mode: the service user, nobody else.
sudo ls -la /var/lib/acme-agent/keyright/
#    drwx------ acme acme .     -rw------- acme acme lease.json

# 4. Restart: the lease survives, no second seat. Dashboard: still 1 device.
sudo systemctl restart acme-agent &amp;&amp; sudo journalctl -u acme-agent -n 3
#    → "License lease refreshed; Valid, 364 days remaining."

# 5. The loop. Block the licensing service for an hour and watch the warning line; unblock and watch it recover.
#    Then confirm with the dashboard's last-seen time that the lease is refreshed every 6 hours without a restart.
</code></pre>
<p>Add the one that matters for a fleet: provision a second host from the same image and run step 1 on both. If the two ids match, stop and fix the image; nothing after that is meaningful until they differ.</p>
<h2>What to take away</h2>
<p>Keyright treats Linux and macOS as first-class, and the SDK calls are identical, but the platform underneath a licensing SDK is not. The data folder is <code>~/.local/share</code> on both and is per user, so a hardened service needs an explicit home — <code>StateDirectory=</code> gives it one, and <code>LoadCredential=</code> hands it a license file or key without a single secret in the environment. The fingerprint anchors on <code>/etc/machine-id</code> or the <code>IOPlatformUUID</code> instead of a registry value, which is just as stable — as long as your images do not ship one id to forty hosts, and your containers do not ship none. And the lease is refreshed by relaunching, which a daemon never does, so give it a loop. Make the paths explicit, print them and the anchor source in a diagnostic, and the day a support engineer asks why a licensed service went Free on day 15, the answer is already in the log.</p>
]]></content:encoded></item><item><title><![CDATA[Decompiling extension methods: the [Extension] attribute is the only trace, the receiver was just the first argument, and the call that looks static]]></title><description><![CDATA[An extension method is one of the purest examples of C# sugar that evaporates in IL. You write this on the first parameter, call it as text.WordCount()…]]></description><link>https://delta1labs.hashnode.dev/decompiling-extension-methods-the-extension-attribute-is-the-only-trace-the-receiver-was-just-the-fi-mwaxzw77</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/decompiling-extension-methods-the-extension-attribute-is-the-only-trace-the-receiver-was-just-the-fi-mwaxzw77</guid><category><![CDATA[glassnet]]></category><category><![CDATA[decompilation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[#DeepDive  ]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Tue, 10 Nov 2026 17:06:48 GMT</pubDate><content:encoded><![CDATA[<p>An extension method is one of the purest examples of C# sugar that evaporates in IL. You write <code>this</code> on the first parameter, call it as <code>text.WordCount()</code>, and feel like you added a method to <code>string</code>. The runtime sees none of that. There is a static method, there is a static call, and there is one attribute — <code>[Extension]</code> — left behind like a note that says <em>this could have been written with a dot</em>. A decompiler's whole job here is to read that note and decide to print the dot, then get the precedence right so a ten-operator LINQ pipeline comes back as a pipeline and not a ten-deep nest. This post walks it for <a href="https://delta1labs.com/products/glass">Glass.NET</a>.</p>
<h2>The method: a static method wearing one attribute</h2>
<p>Start with the definition. A trivial extension on <code>string</code>:</p>
<pre><code class="language-csharp">public static class StringExtensions
{
    public static int WordCount(this string s)
        =&gt; s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}
</code></pre>
<p>Compile it and look at what <code>this</code> became. It is gone from the parameter — <code>s</code> is an ordinary <code>string</code> parameter — and in its place are two <code>[Extension]</code> attributes, one on the method and one on the class:</p>
<pre><code class="language-text">.class public abstract sealed auto ansi beforefieldinit StringExtensions
    extends [System.Runtime]System.Object
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )

    .method public hidebysig static int32 WordCount(string s) cil managed
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
        // ... ldarg.0; call String::Split(...); ldlen; ...
    }
}
</code></pre>
<p>That is the entire difference between an extension method and a static helper. The method is <code>public static</code>, the first parameter is a normal parameter of a normal type, there is no special calling convention. The attribute on the <em>method</em> is what lets a decompiler render the first parameter as <code>this s</code>; the attribute on the <em>class</em> is what tells a consuming compiler the class is worth scanning for extensions in the first place. Strip both and you have <code>StringExtensions.WordCount(x)</code> and nothing else; add them and <code>x.WordCount()</code> becomes legal too.</p>
<h2>The call site: byte-identical to a static call</h2>
<p>Now call it two ways and compare the IL:</p>
<pre><code class="language-csharp">int a = "the quick brown fox".WordCount();         // fluent
int b = StringExtensions.WordCount("the quick brown fox");   // explicit static
</code></pre>
<p>Both compile to the same three instructions — load the string, <code>call</code> the static method, store:</p>
<pre><code class="language-text">ldstr      "the quick brown fox"
call       int32 StringExtensions::WordCount(string)
stloc.0
</code></pre>
<p>There is no second form. The CLR has no concept of an extension call, so the <code>this</code> is resolved and discarded at compile time, and the call site keeps no record of which syntax the author used. This is the crux of the whole problem: a decompiler <strong>cannot</strong> learn from the call whether the source was fluent. It can only look at the method being called, see <code>[Extension]</code> on it, and <em>choose</em> to print the call as a member access on its first argument — <code>"the quick brown fox".WordCount()</code> — because that is the idiomatic form and almost always the original. Both renderings are legal and compile identically, so the choice is readability, not correctness. Glass.NET makes it, which is why the output reads like C# a human wrote rather than like a macro expansion.</p>
<figure class="my-8">Fluent syntax evaporates; the [Extension] attribute brings it backtext.WordCount()StringExtensions.WordCount(text)ldstr textcall WordCount(string)stloc.0metadata[Extension] on class + methodGlass outputtext.WordCount()IL: an outermost-last nestcall ToList(call Select(call Where(source, p), f))each operator has [Extension] → unrollsource.Where(p).Select(f).ToList()</figure>

<h2>The chain: one rule, applied outward</h2>
<p>The reason the dot matters is LINQ, where reading the IL literally produces something correct and unreadable. Take a pipeline:</p>
<pre><code class="language-csharp">var top = people.Where(p =&gt; p.Active).Select(p =&gt; p.Name).OrderBy(n =&gt; n).ToList();
</code></pre>
<p>Every one of <code>Where</code>, <code>Select</code>, <code>OrderBy</code>, <code>ToList</code> is a static extension method on <code>Enumerable</code>, so the IL is a nest — the outermost call emitted last, each result feeding the next call's first argument:</p>
<pre><code class="language-text">call ...ToList( call ...OrderBy( call ...Select( call ...Where(people, &lt;lambda&gt;), &lt;lambda&gt;), &lt;lambda&gt;))
</code></pre>
<p>Read that literally and you get <code>Enumerable.ToList(Enumerable.OrderBy(Enumerable.Select(Enumerable.Where(people, …), …), …))</code> — a four-deep nest you have to read inside-out. Because each method carries <code>[Extension]</code>, Glass.NET applies the one rule repeatedly: print each static call as a member access on its <em>first argument</em>. The innermost <code>Where(people, …)</code> becomes <code>people.Where(…)</code>; that whole expression is the first argument of <code>Select</code>, so it reads <code>people.Where(…).Select(…)</code>; and so on outward until the nest has unrolled into the original chain. The precedence has to be exact — a lambda body with a conditional, or a cast inserted mid-chain, needs the right parentheses or the meaning shifts — but the <em>shape</em> falls straight out of applying "member access on the first argument" from the innermost call out.</p>
<h2>Why the attribute, not the call, is the source of truth</h2>
<p>It is worth being precise about what Glass trusts. The call site is ambiguous by construction — it is the same IL for both syntaxes — so it carries no signal. The method's metadata is unambiguous: <code>[Extension]</code> is present or it is not. So Glass keys its decision on the method:</p>
<ul>
<li>Target method has <code>[Extension]</code> → render the call as <code>arg0.Method(rest)</code>.</li>
<li>Target method does not → render it as <code>Type.Method(args)</code>.</li>
</ul>
<p>This also handles the awkward real cases correctly. If someone deliberately wrote <code>Enumerable.Where(list, p)</code> in explicit static form, Glass still prints <code>list.Where(p)</code> — which is fine, because it compiles back to the same IL and reads better. And a <em>generic</em> extension method like <code>Select&lt;TSource, TResult&gt;</code> carries the attribute just the same; Glass renders <code>src.Select(f)</code> and lets C#'s type inference recover the type arguments at recompile, only spelling them out when inference cannot. The attribute is a reliable, local signal on the method being called, which is exactly what a decompiler wants — no global analysis, no guessing from surrounding <code>using</code> directives that are not even in the IL.</p>
<h2>The C# 14 extension-block shape</h2>
<p>Newer code may use extension blocks, which group members under one receiver and, for the first time, allow extension <em>properties</em>:</p>
<pre><code class="language-csharp">public static class StringExtensions
{
    extension(string s)
    {
        public int  WordCount =&gt; s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
        public bool IsBlank()  =&gt; string.IsNullOrWhiteSpace(s);
    }
}
</code></pre>
<p>This is new syntax, not new runtime. The members still lower to static methods on the enclosing static class, marked so a consumer's compiler can route <code>name.WordCount</code> and <code>name.IsBlank()</code> to them — an extension property becomes a static accessor method, an extension method a static method with the receiver as its first parameter, plus grouping metadata tying several members to one receiver. A decompiler reading such an assembly sees static methods with marker metadata and a receiver parameter, much as before. Glass.NET reconstructs them as extensions; a plain extension method can be shown either as the classic <code>this</code>-parameter form or inside a block, but an extension <em>property</em> has no classic spelling, so it comes back as a block. The semantics are identical either way, because <a href="https://delta1labs.com/blog/decompile-static-abstract-interface-members">the lowered static methods are identical</a> — the skin changed, not the IL.</p>
<h2>The takeaway</h2>
<p>An extension method leaves exactly one trace in metadata: the <code>[Extension]</code> attribute, on the method so the first parameter can wear <code>this</code>, and on the class so a compiler knows to look there. The call site keeps no trace at all — <code>text.WordCount()</code> and <code>StringExtensions.WordCount(text)</code> are byte-identical IL — so a decompiler cannot learn the fluent form from the call and must read it off the method instead. Glass.NET does exactly that: it prints any static call whose target carries <code>[Extension]</code> as a member access on the first argument, applies that one rule from the innermost call outward to unroll a LINQ nest back into a chain, and recognises the C# 14 extension-block lowering as the same static methods under a newer syntax. The result reads like the pipeline that was written, not the nest the compiler left — because the decompiler trusted the attribute, which is the only thing that survived.</p>
]]></content:encoded></item><item><title><![CDATA[Licensing an MSIX-packaged .NET app: AppData virtualization, the uninstall that deletes your lease cache, and where a Keyright license should]]></title><description><![CDATA[Ask a .NET desktop developer where their app keeps its settings and they will say %LocalAppData%. Ask what happens to those files when the app is…]]></description><link>https://delta1labs.hashnode.dev/licensing-an-msix-packaged-net-app-appdata-virtualization-the-uninstall-that-deletes-your-lease-cach</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/licensing-an-msix-packaged-net-app-appdata-virtualization-the-uninstall-that-deletes-your-lease-cach</guid><category><![CDATA[keyright]]></category><category><![CDATA[licensing]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[#DeepDive  ]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Tue, 10 Nov 2026 17:02:24 GMT</pubDate><content:encoded><![CDATA[<p>Ask a .NET desktop developer where their app keeps its settings and they will say <code>%LocalAppData%</code>. Ask what happens to those files when the app is uninstalled and they will say "nothing, that is the user's data". Both answers are right for an MSI. Both are wrong for an MSIX package — and MSIX is now how desktop apps arrive on Windows machines, whether through <code>winget install</code>, an Intune deployment, or the Store.</p>
<p>That matters to licensing more than to almost anything else an app writes, because a licensing SDK keeps exactly the kind of small, important files whose location and lifetime you need to understand: a signed activation lease that lets the app run offline, a local timestamp that stops a trial from being restarted, a record of the last time the clock was seen. This post looks at what the <a href="https://delta1labs.com/products/keyright/">Keyright</a> SDK writes, where those bytes actually land inside a packaged app, what an uninstall does to them, and how to make the whole thing explicit instead of letting a file-system filter decide.</p>
<h2>What the SDK writes, and where it thinks it is writing it</h2>
<p>A Keyright client is initialised once with the product name and the embedded public key, and from then on it keeps up to three files for that product:</p>
<pre><code class="language-text">{LocalApplicationData}\Keyright\{product}\lease.json     signed activation lease (cached after ActivateAsync)
{LocalApplicationData}\Keyright\{product}\state.json     first-seen / last-seen timestamps (trial clock, clock-tamper)
{LocalApplicationData}\Keyright\{product}\license.json   default location for an offline license file
</code></pre>
<p>There is a fourth, <code>{LocalApplicationData}\Keyright\machine.id</code>, which only exists as a last resort: the machine fingerprint is anchored on the OS machine GUID, and the random persisted id is written only if that GUID cannot be read at all. On a Windows machine it normally never exists.</p>
<p>Every one of those paths comes from <code>Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)</code>, and that call returns the same thing inside an MSIX package as outside it: <code>C:\Users\dana\AppData\Local</code>. The SDK, your app, and anything that logs <code>client.LeaseCachePath</code> will all report that path. It is just not where the file is.</p>
<h2>Where it actually goes</h2>
<p>A desktop app packaged with the <code>runFullTrust</code> capability runs inside a lightweight container, and one of the things that container does is <strong>file-system write virtualization</strong> for the user's AppData folders. When the process writes to <code>%LocalAppData%\Keyright\acme-studio\lease.json</code>, a filter driver redirects the write to</p>
<pre><code class="language-text">%LocalAppData%\Packages\&lt;PackageFamilyName&gt;\LocalCache\Local\Keyright\acme-studio\lease.json
</code></pre>
<p>The redirect is invisible to the process. <code>File.WriteAllText</code> succeeds, <code>File.Exists</code> returns <code>true</code>, <code>Directory.EnumerateFiles</code> lists the file — all against the <em>virtual</em> path, which the container presents as a merged view: reads check the package's <code>LocalCache</code> first and fall through to the real <code>%LocalAppData%</code> if the file is not there, and writes always land in <code>LocalCache</code> (copy-on-write if the real file existed). Open File Explorer at <code>C:\Users\dana\AppData\Local\Keyright</code> and there is nothing in it; the files are two levels down inside <code>Packages</code>.</p>
<figure class="my-8">A packaged app and its licensing filesAcme Studio (MSIX, runFullTrust)GetFolderPath(LocalApplicationData)→ C:\Users\dana\AppData\Localwrite Keyright\acme-studio\lease.jsonwrite …\state.json, read …\license.jsonwhere the bytes land (virtualized)%LocalAppData%\Packages\Acme.Studio_8wekyb3d8bbwe\  LocalCache\Local\Keyright\acme-studio\lease.json  LocalCache\Local\Keyright\acme-studio\state.jsondeleted with the package on uninstall · kept across updatesthe real folder (unpackaged installs write here)C:\Users\dana\AppData\Local\Keyright\acme-studio\reads fallthroughfingerprint anchor — outside the package, not virtualizedHKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid  →  machineId  (same before and after uninstall)Write virtualization moves the cache; it does not move the machine. The seat is bound to the machine.So the cache can vanish and the seat is still the same seat when the app comes back.</figure>

<p>Three consequences fall out of that, and they are the whole reason this post exists.</p>
<p><strong>The lease survives package updates.</strong> <code>LocalCache</code> belongs to the package <em>family</em>, not the package <em>version</em>. When <code>winget upgrade</code> installs 2.4 over 2.3, the folder is untouched, the cached lease is still there, and the app starts licensed. Calling <code>ActivateAsync</code> at startup — the pattern the SDK recommends anyway — refreshes it silently when the machine is online.</p>
<p><strong>The lease does not survive uninstall.</strong> Removing the package removes <code>%LocalAppData%\Packages\&lt;family&gt;</code> with everything in it. <code>lease.json</code>, <code>state.json</code>, a <code>license.json</code> the user pasted into the default location: gone. This is the behaviour an MSI never had, and it is the one that surprises people.</p>
<p><strong>The machine fingerprint is unaffected by either.</strong> The anchor the fingerprint is built on is <code>HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid</code>, which belongs to the Windows installation, sits outside the package, and is read — not written — by the SDK. HKLM reads are not virtualized; the package's virtual registry overlays only the keys the package itself carries. The machine name and OS version, the two softer signals, are likewise not package state. So the fingerprint the app presents to the licensing service is byte-for-byte the same before an uninstall and after a reinstall.</p>
<h2>What uninstall and reinstall do to the seat</h2>
<p>Put the last two consequences together and the seat accounting follows.</p>
<p>The seat is not a file on the machine. It is a record on the licensing service that says <em>this license, this machineId</em>. When the customer uninstalls the package, nothing tells the service anything — the lease cache is deleted locally, the seat stays bound. When they reinstall and enter their key again, the app fingerprints the same machine, sends the same <code>machineId</code>, and the service does what it does for any machine it has already seen: refreshes the existing activation and returns a fresh lease. <strong>Re-activation on the same machine is idempotent.</strong> No second seat is consumed, the dashboard still shows one device, and the customer never knew there was a question.</p>
<figure class="my-8">One machine, one seat — whatever the package does1 · install + activatemachineId KRM1:abc…lease.json cachedseats 1 / 32 · winget upgradeLocalCache keptlease still validseats 1 / 33 · uninstallLocalCache deletedlease gone locallyseats 1 / 3 (still)4a · reinstall, same PCmachineId KRM1:abc…idempotent → refreshseats 1 / 34b · new laptop insteadmachineId KRM1:9f0…new seat → 2 / 3old seat still boundThe uninstall told the service nothing, so the oldseat is only reused if the same machine comes back.For a move: DeactivateAsync in-app before uninstalling,or free the dead machine from the license's Devices panel.</figure>

<p>The case that does go wrong is the one where the customer uninstalled <em>because they were leaving the machine</em>. They retire a laptop, set up a new one, install the package, enter the key — and the new laptop is a new fingerprint. If the license had one seat, the service answers <code>LicenseStatus.SeatLimit</code>: all seats in use, free one first. The old laptop cannot free it; the app is no longer on it.</p>
<p>Two things handle that, and neither is specific to MSIX, but MSIX makes them matter more because uninstalling is so much cleaner than it used to be:</p>
<ul>
<li><strong>Give the user a deactivate in the app.</strong> <code>DeactivateAsync(key)</code> releases this machine's seat on the service and clears the local lease. Put it where a user who is about to retire a machine will find it — a "Move this license to another computer" item next to "Enter license key" — and say what it does. A packaged app has no uninstall hook you can run code from, so the deactivate has to happen <em>before</em> the uninstall, which means the UI has to invite it.</li>
<li><strong>Free dead machines vendor-side.</strong> The license's Devices panel in the Keyright dashboard lists every bound machine with a <em>Free seat</em> action, and <code>POST /admin/licenses/{id}/free-seat</code> does the same from a support script. The customer's next activation takes the freed seat.</li>
</ul>
<p>What you should <em>not</em> do is try to reconstruct a deactivation from the uninstall. There is no package-level uninstall script in MSIX by design, and a licensing call that runs during uninstall would be the least reliable network request in your product.</p>
<h2>Know you are packaged, and say where the files go</h2>
<p>Relying on the redirect is fine until a support engineer asks for <code>lease.json</code> and spends twenty minutes in the wrong folder. The cleaner approach is to detect package identity at startup and hand the SDK explicit paths inside the package's own data folder, so the path the app logs is the path the bytes are in.</p>
<p>Package identity comes from <code>GetCurrentPackageFullName</code> in <code>kernel32</code>. Outside a package it returns <code>APPMODEL_ERROR_NO_PACKAGE</code> (15700); inside one it returns the package full name.</p>
<pre><code class="language-csharp">using System.Runtime.InteropServices;
using System.Text;

public static class PackageContext
{
    private const int APPMODEL_ERROR_NO_PACKAGE = 15700;

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
    private static extern int GetCurrentPackageFullName(ref int length, StringBuilder? fullName);

    /// &lt;summary&gt;The package full name when running packaged; null when unpackaged or not on Windows.&lt;/summary&gt;
    public static string? FullName { get; } = Resolve();

    public static bool IsPackaged =&gt; FullName is not null;

    private static string? Resolve()
    {
        if (!OperatingSystem.IsWindowsVersionAtLeast(10)) return null;
        int length = 0;
        int rc = GetCurrentPackageFullName(ref length, null);     // ERROR_INSUFFICIENT_BUFFER when packaged
        if (rc == APPMODEL_ERROR_NO_PACKAGE) return null;
        var sb = new StringBuilder(length);
        rc = GetCurrentPackageFullName(ref length, sb);
        return rc == 0 ? sb.ToString() : null;
    }
}
</code></pre>
<p>With that, the client is built in one of two shapes. Unpackaged, the SDK defaults are right. Packaged, point every path at <code>ApplicationData.Current.LocalFolder</code> — the package's <code>LocalState</code> folder, which is the documented home for a packaged app's data, is never virtualized because it is already package-private, survives updates, and is deleted on uninstall exactly like <code>LocalCache</code>:</p>
<pre><code class="language-csharp">using Keyright;
using Windows.Storage;                       // needs a Windows TFM, e.g. net8.0-windows10.0.19041.0

static KeyrightClient CreateLicenseClient()
{
    var options = new KeyrightOptions
    {
        Product         = "acme-studio",
        PublicKeyBase64 = AcmeKeys.LicensePublicKey,       // public key only; safe to ship in the package
        ServiceUrl      = "https://licensing.acme.example",
    };

    if (PackageContext.IsPackaged)
    {
        // Explicit, package-private, visible in Explorer at the path we log.
        string root = Path.Combine(ApplicationData.Current.LocalFolder.Path, "keyright");
        options.LeaseCachePath     = Path.Combine(root, "lease.json");
        options.LocalStatePath     = Path.Combine(root, "state.json");
        options.DefaultLicensePath = Path.Combine(root, "license.json");
    }

    return KeyrightClient.Initialize(options);
}
</code></pre>
<p>The SDK creates the directory on first write, so there is nothing else to set up. If your project targets plain <code>net8.0-windows</code> without a Windows SDK version, <code>GetCurrentPackageFamilyName</code> from the same DLL gives you the family name, and <code>LocalState</code> lives at <code>%LocalAppData%\Packages\&lt;family&gt;\LocalState</code>.</p>
<p>Then add the diagnostic that turns the support ticket into a one-liner — the same idea as the <code>--site-info</code> command in the <a href="https://delta1labs.com/blog/keyright-site-license-domain-binding/">site-license post</a>: print what the gate will see, from the code path the gate uses.</p>
<pre><code class="language-csharp">// acme-studio --license-info
Console.WriteLine($"Packaged     : {(PackageContext.IsPackaged ? PackageContext.FullName : "no")}");
Console.WriteLine($"Lease cache  : {License.LeaseCachePath}  (exists: {File.Exists(License.LeaseCachePath)})");
Console.WriteLine($"Local state  : {License.LocalStatePath}");
Console.WriteLine($"License file : {License.DefaultPath}");
Console.WriteLine($"Machine id   : {MachineFingerprint.Current().ToBoundString()}");
</code></pre>
<p>The machine id line is the one a support engineer needs to match a device in the dashboard, and it prints the same value packaged or not — which is the point.</p>
<h2>A shared PC: two users, one seat</h2>
<p>MSIX packages are registered per user. On a shared workstation where Dana and Sam both use the app, each has their own <code>Packages\&lt;family&gt;</code> folder, their own <code>LocalCache</code>, their own lease. Each of them has to activate once. That sounds like two seats and it is not: both activations fingerprint the same machine, send the same <code>machineId</code>, and the service binds one seat and refreshes it twice. The per-user cache is a convenience for the <em>lease</em>; the <em>seat</em> was always per machine.</p>
<p>The one thing to avoid is "fixing" this by moving the lease to <code>%ProgramData%</code> so both users share one file. Writes outside the user's AppData are not virtualized, so it would work, but you then own the ACLs — a folder Dana's installer created is not writable by Sam — for no gain, because the seat count is already right.</p>
<h2>Shipping a license inside the package</h2>
<p>Not every license is activated by the user. Site licenses, OEM builds and air-gapped deployments ship a signed license file with the product, and a package is the right vehicle for it: the install directory under <code>C:\Program Files\WindowsApps\&lt;PackageFullName&gt;</code> is read-only, signed as part of the package, and <code>AppContext.BaseDirectory</code> resolves there at runtime.</p>
<pre><code class="language-xml">&lt;!-- Acme.Studio.csproj: bundle the signed site license as content --&gt;
&lt;ItemGroup&gt;
  &lt;Content Include="licenses\license.json" CopyToOutputDirectory="PreserveNewest" /&gt;
&lt;/ItemGroup&gt;
</code></pre>
<pre><code class="language-csharp">// Site/OEM build: the license is part of the package. Explicit file path = authoritative.
options.LicenseFilePath = Path.Combine(AppContext.BaseDirectory, "licenses", "license.json");
</code></pre>
<p><code>LicenseFilePath</code> is an <em>explicit</em> source in the SDK's precedence rules: when it is set, the ambient sources — the <code>KEYRIGHT_LICENSE</code> environment variable, a config path, the default AppData path — are not consulted, and a missing or invalid file is a <code>NoLicense</code> or <code>SignatureInvalid</code> result rather than a fall-through. That is what you want for a build whose licensing is a deployment decision rather than a user one. The two signatures do different jobs: the package signature says the bytes came from you; the license signature, verified against the public key you embedded, says the <em>claims</em> came from you. The read-only install directory keeps anyone from editing the file in place, but that is a convenience — it is the signature that makes the edit pointless.</p>
<p>For a mixed product — most customers activate with a key, enterprise customers get a site file — leave <code>LicenseFilePath</code> unset in the retail package and let the site customers drop the file at <code>DefaultPath</code>, which under the explicit-paths setup above is a stable, documentable location inside <code>LocalState</code>.</p>
<h2>The trial that lives in a file</h2>
<p>One Keyright behaviour deserves a specific MSIX note. A trial activated online carries an expiry the service wrote at issuance; the clock is on the service and an uninstall changes nothing. A trial shipped as an <em>offline</em> signed license file with <code>trialDays</code> and no baked-in expiry is timed from first use on this machine, and the first-seen timestamp that anchors it is kept in <code>state.json</code>. Under a packaged app that file lives in the package's data folder, and uninstall deletes it — so an uninstall/reinstall cycle restarts the local clock.</p>
<p>The fix is to not keep the trial clock in package-scoped storage. Either issue trials online so the expiry is fixed by the service, or point <code>LocalStatePath</code> at a per-user location that is not inside <code>Packages\&lt;family&gt;</code> — which, in a virtualized app, means disabling write virtualization for that write or choosing a non-AppData path. The <a href="https://delta1labs.com/blog/add-free-trial-to-your-software/">post on trials that don't leak</a> covers the general shape; the MSIX-specific lesson is just that "the user's AppData" is not where the file is.</p>
<h2>When to turn virtualization off</h2>
<p>You can tell Windows not to redirect AppData writes for your package:</p>
<pre><code class="language-xml">&lt;Package
  xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
  xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
  xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
  IgnorableNamespaces="desktop6 rescap"&gt;

  &lt;Properties&gt;
    &lt;desktop6:FileSystemWriteVirtualization&gt;disabled&lt;/desktop6:FileSystemWriteVirtualization&gt;
    &lt;desktop6:RegistryWriteVirtualization&gt;disabled&lt;/desktop6:RegistryWriteVirtualization&gt;
  &lt;/Properties&gt;

  &lt;Capabilities&gt;
    &lt;rescap:Capability Name="runFullTrust" /&gt;
    &lt;rescap:Capability Name="unvirtualizedResources" /&gt;
  &lt;/Capabilities&gt;
&lt;/Package&gt;
</code></pre>
<p>With that in place, <code>%LocalAppData%\Keyright\acme-studio\lease.json</code> is a real file at the real path, it survives uninstall, and an unpackaged build of the same product reads the same cache. That last point is the only strong reason to do it: if you ship both an MSI and an MSIX edition, or a support tool that inspects the licensing files directly, a shared unvirtualized location keeps them consistent. Otherwise the default is better — a cache that dies with the package is exactly the right lifetime for a cache, the seat survives it anyway, and <code>unvirtualizedResources</code> is a restricted capability that the Store will ask you to justify (sideloaded and winget packages can declare it without ceremony).</p>
<h2>Verify it</h2>
<p>The whole argument above is testable on one machine in a few minutes, and it is worth doing once before you ship the first packaged build.</p>
<pre><code class="language-powershell"># 1. Install the package and activate from inside the app. Then find the cache.
$pkg = Get-AppxPackage -Name Acme.Studio
Get-ChildItem "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)\LocalState\keyright"
#    → lease.json, state.json. Dashboard: the license shows 1 device, your machine id.

# 2. Upgrade in place; the files and the activation must survive.
winget upgrade Acme.Studio
Get-ChildItem "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)\LocalState\keyright"

# 3. Uninstall; the folder is gone. Dashboard: still 1 device — nothing told it.
Remove-AppxPackage -Package $pkg.PackageFullName
Test-Path "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)"      # False

# 4. Reinstall and activate again. Dashboard: still 1 device, same machine id, lease refreshed.
</code></pre>
<p>Add one more step if you offer an in-app deactivate: run it before step 3 and confirm the dashboard shows 0 devices before the uninstall, then 1 again after step 4. (For debugging only, <code>Remove-AppxPackage -PreserveApplicationData</code> keeps the data folder through an uninstall, which is handy when you want to test the update path without re-activating.)</p>
<p>And the test that catches the trial case, if you ship offline trial files: activate a <code>trialDays</code> trial, note the expiry the app shows, uninstall, reinstall, and confirm the expiry is the <em>same</em> — which it will be only if <code>state.json</code> outlived the package or the trial clock is on the service.</p>
<h2>What to take away</h2>
<p>MSIX moves your app's AppData without telling your app, deletes it on uninstall, and leaves the machine itself alone. For licensing that is almost entirely good news: the cached lease is a cache and behaves like one, the fingerprint anchor is not package state so a reinstall is the same machine, and idempotent activation means the customer who reinstalls never burns a seat. The two things to design for are the customer who uninstalls to <em>move</em> — give them a deactivate they can find — and the one file that should not be a cache, the local trial clock. Make the paths explicit, print them in a diagnostic, and the day a support engineer needs <code>lease.json</code> they will find it on the first try.</p>
]]></content:encoded></item><item><title><![CDATA[Decompiling extension methods: the [Extension] attribute is the only trace, the receiver was just the first argument, and the call that looks static]]></title><description><![CDATA[An extension method is one of the purest examples of C# sugar that evaporates in IL. You write this on the first parameter, call it as text.WordCount()…]]></description><link>https://delta1labs.hashnode.dev/decompiling-extension-methods-the-extension-attribute-is-the-only-trace-the-receiver-was-just-the-fi</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/decompiling-extension-methods-the-extension-attribute-is-the-only-trace-the-receiver-was-just-the-fi</guid><category><![CDATA[glassnet]]></category><category><![CDATA[decompilation]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[#DeepDive  ]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Tue, 10 Nov 2026 17:00:49 GMT</pubDate><content:encoded><![CDATA[<p>An extension method is one of the purest examples of C# sugar that evaporates in IL. You write <code>this</code> on the first parameter, call it as <code>text.WordCount()</code>, and feel like you added a method to <code>string</code>. The runtime sees none of that. There is a static method, there is a static call, and there is one attribute — <code>[Extension]</code> — left behind like a note that says <em>this could have been written with a dot</em>. A decompiler's whole job here is to read that note and decide to print the dot, then get the precedence right so a ten-operator LINQ pipeline comes back as a pipeline and not a ten-deep nest. This post walks it for <a href="https://delta1labs.com/products/glass">Glass.NET</a>.</p>
<h2>The method: a static method wearing one attribute</h2>
<p>Start with the definition. A trivial extension on <code>string</code>:</p>
<pre><code class="language-csharp">public static class StringExtensions
{
    public static int WordCount(this string s)
        =&gt; s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}
</code></pre>
<p>Compile it and look at what <code>this</code> became. It is gone from the parameter — <code>s</code> is an ordinary <code>string</code> parameter — and in its place are two <code>[Extension]</code> attributes, one on the method and one on the class:</p>
<pre><code class="language-text">.class public abstract sealed auto ansi beforefieldinit StringExtensions
    extends [System.Runtime]System.Object
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )

    .method public hidebysig static int32 WordCount(string s) cil managed
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
        // ... ldarg.0; call String::Split(...); ldlen; ...
    }
}
</code></pre>
<p>That is the entire difference between an extension method and a static helper. The method is <code>public static</code>, the first parameter is a normal parameter of a normal type, there is no special calling convention. The attribute on the <em>method</em> is what lets a decompiler render the first parameter as <code>this s</code>; the attribute on the <em>class</em> is what tells a consuming compiler the class is worth scanning for extensions in the first place. Strip both and you have <code>StringExtensions.WordCount(x)</code> and nothing else; add them and <code>x.WordCount()</code> becomes legal too.</p>
<h2>The call site: byte-identical to a static call</h2>
<p>Now call it two ways and compare the IL:</p>
<pre><code class="language-csharp">int a = "the quick brown fox".WordCount();         // fluent
int b = StringExtensions.WordCount("the quick brown fox");   // explicit static
</code></pre>
<p>Both compile to the same three instructions — load the string, <code>call</code> the static method, store:</p>
<pre><code class="language-text">ldstr      "the quick brown fox"
call       int32 StringExtensions::WordCount(string)
stloc.0
</code></pre>
<p>There is no second form. The CLR has no concept of an extension call, so the <code>this</code> is resolved and discarded at compile time, and the call site keeps no record of which syntax the author used. This is the crux of the whole problem: a decompiler <strong>cannot</strong> learn from the call whether the source was fluent. It can only look at the method being called, see <code>[Extension]</code> on it, and <em>choose</em> to print the call as a member access on its first argument — <code>"the quick brown fox".WordCount()</code> — because that is the idiomatic form and almost always the original. Both renderings are legal and compile identically, so the choice is readability, not correctness. Glass.NET makes it, which is why the output reads like C# a human wrote rather than like a macro expansion.</p>
<figure class="my-8">Fluent syntax evaporates; the [Extension] attribute brings it backtext.WordCount()StringExtensions.WordCount(text)ldstr textcall WordCount(string)stloc.0metadata[Extension] on class + methodGlass outputtext.WordCount()IL: an outermost-last nestcall ToList(call Select(call Where(source, p), f))each operator has [Extension] → unrollsource.Where(p).Select(f).ToList()</figure>

<h2>The chain: one rule, applied outward</h2>
<p>The reason the dot matters is LINQ, where reading the IL literally produces something correct and unreadable. Take a pipeline:</p>
<pre><code class="language-csharp">var top = people.Where(p =&gt; p.Active).Select(p =&gt; p.Name).OrderBy(n =&gt; n).ToList();
</code></pre>
<p>Every one of <code>Where</code>, <code>Select</code>, <code>OrderBy</code>, <code>ToList</code> is a static extension method on <code>Enumerable</code>, so the IL is a nest — the outermost call emitted last, each result feeding the next call's first argument:</p>
<pre><code class="language-text">call ...ToList( call ...OrderBy( call ...Select( call ...Where(people, &lt;lambda&gt;), &lt;lambda&gt;), &lt;lambda&gt;))
</code></pre>
<p>Read that literally and you get <code>Enumerable.ToList(Enumerable.OrderBy(Enumerable.Select(Enumerable.Where(people, …), …), …))</code> — a four-deep nest you have to read inside-out. Because each method carries <code>[Extension]</code>, Glass.NET applies the one rule repeatedly: print each static call as a member access on its <em>first argument</em>. The innermost <code>Where(people, …)</code> becomes <code>people.Where(…)</code>; that whole expression is the first argument of <code>Select</code>, so it reads <code>people.Where(…).Select(…)</code>; and so on outward until the nest has unrolled into the original chain. The precedence has to be exact — a lambda body with a conditional, or a cast inserted mid-chain, needs the right parentheses or the meaning shifts — but the <em>shape</em> falls straight out of applying "member access on the first argument" from the innermost call out.</p>
<h2>Why the attribute, not the call, is the source of truth</h2>
<p>It is worth being precise about what Glass trusts. The call site is ambiguous by construction — it is the same IL for both syntaxes — so it carries no signal. The method's metadata is unambiguous: <code>[Extension]</code> is present or it is not. So Glass keys its decision on the method:</p>
<ul>
<li>Target method has <code>[Extension]</code> → render the call as <code>arg0.Method(rest)</code>.</li>
<li>Target method does not → render it as <code>Type.Method(args)</code>.</li>
</ul>
<p>This also handles the awkward real cases correctly. If someone deliberately wrote <code>Enumerable.Where(list, p)</code> in explicit static form, Glass still prints <code>list.Where(p)</code> — which is fine, because it compiles back to the same IL and reads better. And a <em>generic</em> extension method like <code>Select&lt;TSource, TResult&gt;</code> carries the attribute just the same; Glass renders <code>src.Select(f)</code> and lets C#'s type inference recover the type arguments at recompile, only spelling them out when inference cannot. The attribute is a reliable, local signal on the method being called, which is exactly what a decompiler wants — no global analysis, no guessing from surrounding <code>using</code> directives that are not even in the IL.</p>
<h2>The C# 14 extension-block shape</h2>
<p>Newer code may use extension blocks, which group members under one receiver and, for the first time, allow extension <em>properties</em>:</p>
<pre><code class="language-csharp">public static class StringExtensions
{
    extension(string s)
    {
        public int  WordCount =&gt; s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
        public bool IsBlank()  =&gt; string.IsNullOrWhiteSpace(s);
    }
}
</code></pre>
<p>This is new syntax, not new runtime. The members still lower to static methods on the enclosing static class, marked so a consumer's compiler can route <code>name.WordCount</code> and <code>name.IsBlank()</code> to them — an extension property becomes a static accessor method, an extension method a static method with the receiver as its first parameter, plus grouping metadata tying several members to one receiver. A decompiler reading such an assembly sees static methods with marker metadata and a receiver parameter, much as before. Glass.NET reconstructs them as extensions; a plain extension method can be shown either as the classic <code>this</code>-parameter form or inside a block, but an extension <em>property</em> has no classic spelling, so it comes back as a block. The semantics are identical either way, because <a href="https://delta1labs.com/blog/decompile-static-abstract-interface-members">the lowered static methods are identical</a> — the skin changed, not the IL.</p>
<h2>The takeaway</h2>
<p>An extension method leaves exactly one trace in metadata: the <code>[Extension]</code> attribute, on the method so the first parameter can wear <code>this</code>, and on the class so a compiler knows to look there. The call site keeps no trace at all — <code>text.WordCount()</code> and <code>StringExtensions.WordCount(text)</code> are byte-identical IL — so a decompiler cannot learn the fluent form from the call and must read it off the method instead. Glass.NET does exactly that: it prints any static call whose target carries <code>[Extension]</code> as a member access on the first argument, applies that one rule from the innermost call outward to unroll a LINQ nest back into a chain, and recognises the C# 14 extension-block lowering as the same static methods under a newer syntax. The result reads like the pipeline that was written, not the nest the compiler left — because the decompiler trusted the attribute, which is the only thing that survived.</p>
]]></content:encoded></item><item><title><![CDATA[Which Nebula.NET edition is right for you?]]></title><description><![CDATA[Nebula.NET comes in three editions — Free, Licensed, and Enterprise. They're all built on the same tested engine; the difference is how much protection…]]></description><link>https://delta1labs.hashnode.dev/which-nebulanet-edition-is-right-for-you</link><guid isPermaLink="true">https://delta1labs.hashnode.dev/which-nebulanet-edition-is-right-for-you</guid><category><![CDATA[guide]]></category><category><![CDATA[nebulanet]]></category><dc:creator><![CDATA[Zero Heartbeat]]></dc:creator><pubDate>Tue, 10 Nov 2026 14:23:11 GMT</pubDate><content:encoded><![CDATA[<p>Nebula.NET comes in three editions — Free, Licensed, and Enterprise. They're all built on the same tested engine; the difference is how much protection, automation, and support you get. Here's how to choose.</p>
<h2>Free — evaluate and protect small projects</h2>
<p>The Free edition is fully functional for trying Nebula.NET and protecting small or hobby projects. You get identifier renaming, a couple of encrypted strings, a couple of control-flow-protected methods, and both the CLI and desktop GUI.</p>
<p><strong>Choose Free if:</strong> you're evaluating the tool, or shipping a small/non-commercial project and basic renaming is enough.</p>
<p><strong>You'll outgrow it when:</strong> you need full string encryption, control-flow flattening on more than a couple of methods, anti-tamper, or the ability to read back crash stack traces.</p>
<h2>Licensed — the full suite for professionals and teams</h2>
<p>Licensed removes all caps and unlocks the complete standard suite: unlimited string encryption and control-flow flattening, <strong>method encryption</strong>, <strong>anti-tamper &amp; anti-debug</strong>, resource and metadata hardening, <strong>Authenticode signing</strong>, <strong>stack-trace de-obfuscation</strong>, and <strong>MSBuild/CI integration</strong> — plus priority email support.</p>
<p>It's available two ways:</p>
<ul>
<li><strong>Annual</strong> — <strong>$599 per seat/year</strong> (always-current updates).</li>
<li><strong>Perpetual</strong> — <strong>$1,499 per seat</strong>, one-time, never expires (includes 1 year of updates and priority support). Best value if you'll use it long-term.</li>
</ul>
<p><strong>Choose Licensed if:</strong> you sell or distribute commercial .NET software and want real, complete protection with support. This is the right fit for most teams.</p>
<h2>Enterprise — protection and control at scale</h2>
<p>Enterprise includes everything in Licensed, plus capabilities larger organizations need:</p>
<ul>
<li><strong>Per-customer watermarking</strong> — trace a leaked or cracked binary back to the exact customer.</li>
<li><strong>Aggressive control-flow tier</strong> for your most sensitive assemblies.</li>
<li><strong>Floating CI-server licenses</strong> — protect on build agents without using developer seats.</li>
<li><strong>On-prem / air-gapped activation</strong> for secure environments.</li>
<li><strong>Volume &amp; site licensing</strong>, purchase orders and invoicing.</li>
<li><strong>Priority support with an SLA</strong> and onboarding.</li>
</ul>
<p><strong>Choose Enterprise if:</strong> you distribute widely and want leak tracing, you have strict CI or security requirements, or you need volume licensing and a formal procurement process.</p>
<h2>Quick decision guide</h2>
<table>
<thead>
<tr>
<th>Your situation</th>
<th>Edition</th>
</tr>
</thead>
<tbody><tr>
<td>Trying it out or a small/hobby project</td>
<td><strong>Free</strong></td>
</tr>
<tr>
<td>Commercial product, want the full suite + support</td>
<td><strong>Licensed</strong></td>
</tr>
<tr>
<td>Prefer a one-time purchase</td>
<td><strong>Licensed (Perpetual)</strong></td>
</tr>
<tr>
<td>Need leak tracing, CI/on-prem licensing, or volume/POs</td>
<td><strong>Enterprise</strong></td>
</tr>
</tbody></table>
<p>See the full <a href="https://delta1labs.com/products/nebula/editions">edition comparison</a>, <a href="https://delta1labs.com/pricing">pricing</a>, or <a href="https://delta1labs.com/products/nebula/enterprise">request an Enterprise quote</a>. Not ready to buy? <a href="https://delta1labs.com/download">Start free</a> — you can upgrade any time by registering a license key; no reinstall required.</p>
]]></content:encoded></item></channel></rss>