Reproducible, Verifiable Builds

Deterministic builds, source link so shipped code can be debugged, and signing, so you can prove what you shipped.

Syntax<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild> <!-- set only in CI -->

Two builds of the same commit should produce the same bytes. When they do, anyone can rebuild your source and check that the binary they were given came from it. That is a reproducible build, and .NET is close to it by default: the Deterministic property is true for SDK-style projects, which replaces the timestamp and the module identifier with values derived from a hash of the inputs.

What counts as an input

More than the source. The compiler version, the referenced assemblies, the analysers, and the current directory path all feed the hash. So the same commit built in /home/alice/app and in /build/app gives different bytes. Setting ContinuousIntegrationBuild to true normalises the paths that get embedded, which is why it belongs in CI and not on a developer machine, where it would break local debugging.

Source link

Source link records the repository URL and the exact commit in the PDB, so a debugger can fetch the matching source for a library you shipped. Since the .NET 8 SDK it is built in and enabled by default for GitHub, Azure Repos, GitLab 12.0 and later, and Bitbucket 4.7 and later. Referencing one of the old Microsoft.SourceLink.* packages now overrides the SDK version rather than adding anything.

Signing

A signature says who built a package and that nobody changed it since. dotnet nuget sign signs a .nupkg with a code-signing certificate, and --timestamper keeps it valid after the certificate expires.

Example

Example · csharp
using System.Security.Cryptography;
using System.Text;

// A stand-in for a build. Same input, same bytes out.
Console.WriteLine($"two builds of the same source match: {Hash("app v1") == Hash("app v1")}");

// A build that stamps the clock into its output can never match,
// which is the problem deterministic compilation removes.
Console.WriteLine($"a timestamped build matches: {Hash("app v1") == Hash("app v1 built 10:04:11")}");

static string Hash(string content)
{
    // HashData is a static helper, added in .NET 5. The longhand is
    // using SHA256 sha = SHA256.Create(); sha.ComputeHash(bytes);
    byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(content));

    // ToHexString returns upper case; lower it so the text is stable.
    return Convert.ToHexString(digest).ToLowerInvariant();
}

// Output:
// two builds of the same source match: True
// a timestamped build matches: False

When to use it

  • Let a customer rebuild your package from the tag and check it matches the one they downloaded.
  • Step into a published library's own source while debugging your service.
  • Prove which commit produced the assembly that is currently running in production.

More examples

Project settings for a verifiable package

This is the standard block for a library you publish for other people. Source link needs no package reference on the .NET 8 SDK and later, only these properties. The alternative to a symbol package is <DebugType>embedded</DebugType>, which puts the PDB inside the assembly: one file to deploy, a slightly larger assembly, and nothing to lose track of.

Example · xml
<PropertyGroup>
  <!-- True by default for SDK projects; stated here to be explicit. -->
  <Deterministic>true</Deterministic>

  <!-- Only in CI. TF_BUILD is set by Azure Pipelines; use
       GITHUB_ACTIONS on GitHub. -->
  <ContinuousIntegrationBuild Condition="'$(TF_BUILD)' == 'true'">true</ContinuousIntegrationBuild>

  <!-- Record the repository URL in the package metadata. -->
  <PublishRepositoryUrl>true</PublishRepositoryUrl>

  <!-- Embed sources git does not track, such as generated files. -->
  <EmbedUntrackedSources>true</EmbedUntrackedSources>

  <!-- Ship symbols as a separate .snupkg alongside the .nupkg. -->
  <IncludeSymbols>true</IncludeSymbols>
  <SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>

Sign and verify a package

Without --timestamper the signature stops validating the day the certificate expires, even for packages signed years earlier, so treat the timestamp as required rather than optional. Signing is also one of the few build steps you cannot run from a laptop in the long term: the private key belongs in a pipeline secret store or an HSM, not in the repository.

Example · bash
# Sign with a PKCS#12 certificate file that holds the private key.
dotnet nuget sign bin/Release/Shipping.Client.1.4.0.nupkg \
    --certificate-path codesign.pfx \
    --certificate-password "$CERT_PASSWORD" \
    --timestamper http://timestamp.example.com

# Or by fingerprint from the machine certificate store.
# .NET 10 rejects SHA-1 fingerprints outright; use SHA-256.
dotnet nuget sign bin/Release/Shipping.Client.1.4.0.nupkg \
    --certificate-fingerprint "$CERT_SHA256" \
    --timestamper http://timestamp.example.com

# Check a package you were given.
dotnet nuget verify bin/Release/Shipping.Client.1.4.0.nupkg

Checking that a build really reproduces

Run this once when you set a repository up, and keep it in the pipeline if the binary is security relevant. If the hashes differ, the usual culprits are a source generator that embeds a timestamp, an SDK version difference between the two runs (pin it with global.json), or an absolute path leaking into the assembly.

Example · bash
# Build the same tagged commit twice, in two different clones,
# with the CI flag set so paths are normalised.
git clone --branch v1.4.0 https://example.com/shipping.git a
git clone --branch v1.4.0 https://example.com/shipping.git b

dotnet build ./a/src/Shipping.Client -c Release -p:ContinuousIntegrationBuild=true
dotnet build ./b/src/Shipping.Client -c Release -p:ContinuousIntegrationBuild=true

# Same bytes means the assembly is a function of the source.
sha256sum ./a/src/Shipping.Client/bin/Release/net10.0/Shipping.Client.dll \
          ./b/src/Shipping.Client/bin/Release/net10.0/Shipping.Client.dll

Discussion

  • Be the first to comment on this lesson.