DEV Community

Cover image for Three Release Contracts for Obfuscated .NET Assemblies
Skater
Skater

Posted on

Three Release Contracts for Obfuscated .NET Assemblies

Obfuscation becomes a release-engineering problem the moment you need to rebuild, verify, or debug what you shipped. This Dev.to article approaches repeatable Opaquer releases through three practical contracts: identity, integrity, and diagnostics.
The goal is not merely to run an obfuscator consistently. It is to ensure that every protected assembly can be traced to known inputs, loaded successfully after transformation, and explained when a production stack trace arrives.


The trap: “The build passed”
A CI job can be green while the release is still operationally weak.
Consider a common sequence:
Build succeeds
→ Opaquer runs
→ ZIP package is published
→ Customer reports a crash two months later
→ Stack trace contains renamed methods
→ Nobody can find the matching map file
At that point, rebuilding may not help. Even when the source is unchanged, differences in the SDK, restored dependencies, obfuscation settings, seed behavior, or signing process can create a different protected assembly. The new map file describes the new assembly—not necessarily the customer’s failing one.
The solution is to make three promises about every protected release.


Contract 1: Release identity
Question: Can we prove exactly what created this protected DLL?
A protected file is identifiable only when its inputs are controlled and recorded. That starts with deterministic compilation, but it extends to the full transformation chain.
For a .NET project, enable deterministic compilation:


net8.0
true
true


That makes the compiler less dependent on machine-specific build details. It does not, by itself, make protection deterministic.
For the full release identity, keep these inputs explicit:
Source commit or release tag
.NET SDK version
Locked dependency graph
Build configuration
Opaquer CLI version
Opaquer .opq project file
Protection seed, where applicable
Signing process and key reference
Put Opaquer settings in Git
An Opaquer .opq file is part of the release definition. It can govern renaming rules, exclusions, string protection, control-flow settings, dependency resolution, and other choices that affect the final artifact.
Do not let those choices exist only in a developer’s GUI session.
A useful repository layout is:
/
├── src/
├── build/
│ ├── opaquer/
│ │ └── production.opq
│ └── scripts/
│ └── protect-release.ps1
├── tests/
└── MyApp.sln
The file name is not important. What matters is that it is version-controlled, reviewed, and attached to the release revision.
If your configuration supports a fixed pseudo-random seed, use a defined seed for the production release path. Otherwise, renamed identifiers can change each time the same application is protected, making exact reproduction and map correlation more difficult.[ppl-ai-file-upload.s3.amazonaws]


Contract 2: Release integrity
Question: Will the protected file load, authenticate, and behave like the tested release artifact?
Obfuscation changes assembly contents and metadata. Strong-name signing is therefore not a step that can safely be treated as “already done.”
This sequence is wrong:
Compile → Strong-name sign → Obfuscate → Ship
After obfuscation, the earlier signature no longer validates the changed content.
Use this sequence instead:
Compile → Obfuscate → Re-sign → Verify → Test → Ship
In an automated pipeline, provide the signing key as a secure build secret. Do not add .snk or .pfx files to the repository.
Here is a compact Windows-based example:
$inputAssembly = "bin\Release\net8.0\MyApp.dll"
$outputFolder = "artifacts\protected"
$outputAssembly = "$outputFolder\MyApp.dll"
$mapFile = "$outputFolder\MyApp.map.xml"
$logFile = "$outputFolder\opaquer.log"

New-Item -ItemType Directory -Force -Path $outputFolder | Out-Null

Opaquer.Console.exe
/i:"$inputAssembly"

/o:"$outputAssembly"
/config:"build\opaquer\production.opq"

/snk:"$env:SIGNING_KEY_PATH"
/map:"$mapFile"

/log:"$logFile"

sn -v $outputAssembly

Get-FileHash -Path $outputAssembly -Algorithm SHA256
The example deliberately produces a log, map, and checksum near the protected output. A robust pipeline should publish all of those items as artifacts, not only the DLL.
Test the output customers receive
A successful build of the original assembly does not prove that the protected assembly works.
Protection can expose assumptions in code that depends on:
• Reflection.
• Serialization.
• Dependency injection.
• WPF binding.
• Plugin discovery.
• ORM metadata.
• Attributes.
• Native or COM interop.
• Dynamically resolved type names.
Run the relevant regression, integration, or smoke tests against the protected binaries. The final package, not the pre-protection build folder, is the product you need to validate.


Contract 3: Release diagnostics
Question: Can we interpret a production failure from this exact release?
Obfuscation purposely transforms names. That is useful for protection, but it means production diagnostics need a reliable reverse reference.
For Opaquer releases, this reference is the rename map:
MyApp.map.xml
A map file is not interchangeable across builds. It must match the exact protected output that generated the stack trace.
Avoid this artifact workflow:
shared-folder/
MyApp.map.xml
That filename is easy to overwrite and offers no reliable association with a shipped release.
Prefer a release-scoped structure:
protected-releases/
└── v4.7.2/
├── MyApp.dll
├── MyApp.map.xml
├── opaquer.log
├── checksums.sha256
└── release-manifest.json
Or generate uniquely named artifacts:
MyApp_v4.7.2_build-1842.dll
MyApp_v4.7.2_build-1842.map.xml
MyApp_v4.7.2_build-1842.log
The map may reveal relationships between protected and original code symbols, so store it in restricted internal artifact storage. Keep it available for the supported lifetime of the product version, but do not distribute it with the public application package.


A small CI pipeline pattern
The CI platform does not matter as much as the artifact discipline. The pipeline should make each release input and output visible.
steps:

  • name: Build release assemblies
    run: dotnet build MyApp.sln -c Release -p:Deterministic=true

  • name: Protect and re-sign
    shell: pwsh
    run: |
    Opaquer.Console.exe
    /i:"bin\Release\net8.0\MyApp.dll"

    /o:"artifacts\protected\MyApp.dll"
    /config:"build\opaquer\production.opq"

    /snk:"${{ secrets.SIGNING_KEY_PATH }}"
    /map:"artifacts\protected\MyApp.map.xml"

    /log:"artifacts\protected\opaquer.log"

  • name: Verify strong name and record hash
    shell: pwsh
    run: |
    sn -v artifacts\protected\MyApp.dll
    Get-FileHash artifacts\protected\MyApp.dll -Algorithm SHA256 |
    Out-File artifacts\protected\checksums.sha256

  • name: Test protected release
    run: dotnet test tests\ProtectedRelease.Tests\ProtectedRelease.Tests.csproj -c Release

  • name: Publish protected release packet
    uses: actions/upload-artifact@v4
    with:
    name: protected-release-${{ github.sha }}
    path: artifacts/protected/
    Treat the example as a pattern, not a copy-and-paste deployment recipe. Adjust paths, secret handling, test projects, and the exact Opaquer CLI arguments to fit the build environment.
    The non-negotiable outcome is that the CI system retains a package that connects the protected binary to its map, log, checksum, and release identity.


    A reproducibility check worth automating
    If identical inputs are intended to create identical protected results, test that expectation directly.
    Run two clean pipeline builds for the same source revision and compare the SHA-256 checksum of the protected file.
    $first = (Get-FileHash ".\run-a\MyApp.dll" -Algorithm SHA256).Hash
    $second = (Get-FileHash ".\run-b\MyApp.dll" -Algorithm SHA256).Hash

if ($first -ne $second) {
throw "Protected output differs between equivalent builds."
}
If the hashes differ, treat the result as a signal to investigate. Typical sources of variation include:
• A different .NET SDK or CLI version.
• A dependency resolving to a different version.
• Generated files that include current dates or times.
• An uncontrolled protection seed.
• A modified .opq project file.
• Different dependency search paths.
• A packaging or signing step that inserts changing metadata.
Do not solve this by simply saving the newest map over the prior one. The artifact history is what makes diagnosis possible.


Failure guide
Failure Likely explanation Pipeline correction
FileLoadException or signature validation error Obfuscation changed a file after it was signed Re-sign the protected output and validate it before publishing
A stack trace cannot be translated The map is missing or belongs to a different build Archive a unique .map.xml file with every release
Two equivalent CI builds produce different hashes One or more compiler, protection, dependency, or packaging inputs changed Pin and record inputs; make deterministic options and seeds explicit
The protection tool cannot find an assembly dependency Dependency paths were available locally but not in CI Provide explicit library paths and preserve dependency context
The protected application fails tests Protection affected reflection, serialization, or a framework contract Add tested exclusions or configuration changes to the tracked .opq file


The release gate checklist
Before publishing a protected .NET release, verify that all of these statements are true:
• The source revision is immutable and recorded.
• The build runs in CI rather than on an unmanaged workstation.
• Deterministic compilation is enabled where appropriate.
• The Opaquer project configuration is version-controlled.
• Protection settings, including any fixed seed, are explicit.
• The protected assembly is signed after transformation.
• The signing key comes from secure pipeline storage.
• Strong-name verification passes on the protected assembly.
• Automated tests execute against protected output.
• The map file, protection log, and checksum are archived with the release.
• The release artifacts are named or indexed by a stable release identifier.
• The protected artifact has a recorded SHA-256 hash.


Closing thought
The strongest improvement is conceptual: stop treating obfuscation as a final click before publishing.
Treat it as a controlled transformation in the delivery pipeline. Once the source, build, protection configuration, signing process, map, log, and checksum all travel together, an obfuscated release becomes something your team can reproduce and debug—not merely something it can ship.

Top comments (1)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Green builds that still ship surprises are the best plot twist nobody asked for.