Cross-platform code signing for .NET - sign NuGet packages, Windows binaries (PE) and installers (MSI) from Linux, macOS or Windows, with the private key held in Azure Trusted Signing or Azure Key Vault, where it stays.
# The point of the tool: a release signed on ubuntu-latest, key never leaving the HSM.
sign-universal sign packages/*.nupkg --trust-signing-root \
--trusted-signing-endpoint https://eus.codesigning.azure.net \
--trusted-signing-account my-account \
--trusted-signing-certificate-profile my-profileSigning a .NET release from Linux CI forces a Windows job into the pipeline, for two separate reasons:
dotnet/signsupports Trusted Signing but is Windows-only. For PE it delegates Authenticode towintrust/mssign32, and its Trusted Signing backend ships a native signtool Dlib with MFC/MSVC dependencies.dotnet nuget signruns on Linux, but only with a certificate whose private key is local. A key in Trusted Signing or Key Vault has no exportable key, so it is unreachable.
Those gaps intersect at exactly what a release pipeline needs: a remote key plus a Linux
agent. jsign solves the Authenticode half
cross-platform and is excellent, but requires a JVM and does not do NuGet packages.
dotnet tool install --global SignUniversal.Cli
sign-universal --versionNeeds .NET 8 or newer; the tool targets net8.0 and rolls forward. dotnet dnx SignUniversal.Cli@<version> runs it without installing, which suits CI.
sign-universal sign packages/*.nupkg # NuGet package (and .snupkg)
sign-universal sign app.exe # PE binary
sign-universal sign installer.msi # MSI package
sign-universal verify app.exe pkg.nupkg # what a signature says about itselfFiles are signed in place, one signing session covers every file given, and everything is
RFC 3161 timestamped by default. verify reports the signer, the embedded chain, whether
the signature is intact and whether it still covers the bytes on disk; it exits non-zero if
not. It deliberately leaves trust to signtool verify /pa and dotnet nuget verify,
which already decide it properly.
For packages, the hashing and the zip surgery that inserts .signature.p7s are NuGet's own
libraries - the format's reference implementation. All this adds is the one thing they
cannot do: a CMS signature from a key it never holds.
The engine ships separately from the tool, so another program can sign without shelling out to a process:
| Package | Adds | Cost |
|---|---|---|
SignUniversal |
PE and MSI Authenticode | System.Security.Cryptography.Pkcs, OpenMcdf |
SignUniversal.Azure |
Trusted Signing and Key Vault key sources | the Azure SDK |
SignUniversal.NuGet |
.nupkg author signing |
NuGet's client libraries |
using IRemoteSigner signer = new TrustedSigningRemoteSigner(endpoint, account, profile);
PeSigner.SignFile("app.exe", signer, HashAlgorithmName.SHA256, timestampUrl);
MsiSigner.SignFile("installer.msi", signer, HashAlgorithmName.SHA256, timestampUrl);Signing happens on a copy that replaces the original only on success, so a lost network or a rejected credential cannot leave behind a file whose old signature is gone and whose new one was never written.
IRemoteSigner is one SignHash method, so the key can stay wherever it already lives -
the Azure implementations are just two of them. Referencing SignUniversal on its own
costs no Azure and no NuGet dependency.
| Source | Options |
|---|---|
| Azure Trusted Signing | --trusted-signing-endpoint, --trusted-signing-account, --trusted-signing-certificate-profile |
| Azure Key Vault | --key-vault-url, --key-vault-certificate |
| Local PKCS#12 | --pfx, with SIGNUNIVERSAL_PFX_PASSWORD or --password-stdin |
| Throwaway | --self-signed, for smoke tests only |
Both cloud sources authenticate through DefaultAzureCredential, so the usual AZURE_*
variables work unchanged. Key Vault needs certificates/get and keys/sign - separate
permissions, and holding the first without the second is the usual surprise.
--export-certificate writes out the certificate that signed, which is what a gallery
wants when it requires registration.
Two requirements are invisible until you try it, and each costs an afternoon:
--trust-signing-rootis required on Linux. Trusted Signing issues from a root Linux trust stores do not carry, and NuGet refuses to sign against a chain it cannot build, so without it you get onlyCertificate chain validation failed.- Microsoft's
timestamp.acs.microsoft.comdoes not chain on a stock Linux agent. The default is DigiCert for that reason.
docs/adopting-sign-universal.md has the full change,
which is two lines of workflow plus those flags.
How much time it saves depends on what else the job does. Signing itself is much the same
speed on either platform; what goes away is Windows runner startup, which costs about 48s
against 10s on ubuntu-latest. A job that only signs gains most of that:
Zomp.SyncMethodGenerator went from 51-90s to 10-12s. A job that also pushes packages
gains proportionally less - Zomp.EFCore.BinaryFunctions went from 41s to 36s while
gaining a verification step. Windows minutes are also billed at twice the rate, so a
private repository saves on both counts.
Authenticode is a format you cannot get almost right, and the verifier that decides is not available where most of this code was written. Four checks stand in for it, all in CI:
- A second implementation, and a corpus.
AuthenticodeDigestReferenceis the digest transcribed straight from the specification, sharing no code with the engine. Pointed at a directory of signed binaries viaSIGNUNIVERSAL_PE_CORPUS, the suite recomputes each digest and finds it inside that file's own signature - the oracle that settled every question the spec leaves ambiguous. A NuGet package cache works. - Structural tests compare the encoded
SpcIndirectDataContentbyte for byte against a Microsoft-signed binary's, and require that signing leave the digest it covers unchanged. signtool verify /paon Windows, for PE and MSI. The Windows leg setsSIGNUNIVERSAL_REQUIRE_SIGNTOOL=1, so a missing signtool fails the build instead of skipping the gate and reporting green.dotnet nuget verifyfor packages, on the same Linux machine that produced the signature. A tamper test keeps it honest.
Both cloud backends have been exercised against live Azure. The tool signs its own
releases, and Zomp.SyncMethodGenerator
is published signed by it from an Ubuntu runner.
Out of scope for v1: MSIX/APPX, CAB, scripts, non-Azure KMS, and PE page hashes.
Design and format handling were informed by jsign - see NOTICE. No
jsign code is included; the acknowledgement is a courtesy, not a licence obligation.
Licensed MIT (see LICENSE).