To publish a WPF application from a CI DevOps pipeline I needed a command line tool to sign ClickOnce manifests and executables. The certificate is an EV Code Signing Certificate stored in an Azure Key Vault. After some research I found tools to sign executables, but no tools to sign the ClickOnce manifest files.
I made a solution combining an open source tool AzureSignTool and the Microsoft ClickOnce Mage tool to sign manifest files. The result is a command line AzureSignToolClickOnce.exe tool to do all the tasks.
First some explanation of what is needed and why.
What is ClickOnce?
ClickOnce is a Microsoft deployment technology to create self-updating applications. Here’s an example of a typical usage:
- Build and publish a .NET WPF application with ClickOnce.
- Upload the publish folder to a web server (Azure Blob Storage).
- The client installs the application from: https://mydomain.com/MyApp/Setup.exe
- Based on the ClickOnce configuration, the app will check for a new version each time the app is started.
Why is an EV Code Certificate needed?
The ClickOnce manifest and executable files are signed with a code certificate to make sure the authenticity of the application’s publisher is verified. Without the usage of an EV Code Signing Certificate Windows SmartScreen will raise a warning to the user: “Windows Defender SmartScreen prevented an unrecognized app from starting. Running this app might put your PC at risk”.
EV Code Signing (Extended Validation) entails extensive vetting of the publisher and can be obtained for example at globalsign.com. To make sure the private key of the certificate is secure, it is required to have the private key stored on hardware, such as a USB token.
From the command line the signing is done with signtool.exe on the machine with the USB token.
signtool sign /td sha256 /fd sha256 /n "my cert name" "MyApp.exe"
Azure DevOps pipeline
The USB token is of course not available on the virtual machine running a DevOps pipeline task. The alternative option is to have the certificate stored in a managed HSM (Hardware Security Module) available in the Azure Key vault. For more details how to buy and install a code certificate check the GlobalSign article Generating and Importing a Certificate into Microsoft Azure Key Vault.
What we need is a command line tool to delegate the signing with Authenticode to the Key Vault. This option is not supported at the moment in the default sign tool of Microsoft.
Azure Sign Tool
Howerver with the Azure Sign Tool it is possible to sign files with a certificate stored in the Key Vault. More information how to use it on the project website https://github.com/vcsjones/AzureSignTool
Example usage:
AzureSignTool.exe sign
-fd sha384 -kvu https://my-vault.vault.azure.net \
-kvi 01234567-abcd-ef012-0000-0123456789ab \
-kvt 01234567-abcd-ef012-0000-0123456789ab \
-kvs \
-kvc my-key-name \
-tr http://timestamp.digicert.com \
-td sha384 \
-v \
-ifl C:\list\of\file\to\sign.txt \
fileToSign.exe
The Azure Sign Tool can be added to the msbuild publish task to sign the executables. However this is not possible for the manifest file. To sign a manifest file the tool mage.exe is needed. Mage.exe is a Manifest Generation and Editing command line Tool for .NET Framework applications. However mage.exe can’t be used for certificates stored in the Azure Key Vault so far. Hopefully someone can bring this article to the attention of the .NET development team to have this included.
Solution
After some research on this I was inspired by the project https://github.com/dotnet/SignService, but couldn’t get it to work and I preferred a command line tool for easy integration in the DevOps pipeline.
I combined AzureSignTool with the source code of mage.exe to create a new command line tool AzureSignToolClickOnce.exe
The AzureSignToolClickOnce.exe can be used like this:
- Publish the application with msbuild, for example in a batch file:
set version=1.1.001.0
msbuild.exe /target:publish
/p:Configuration=Release
/p:AssemblyVersionNumber=%version%
/p:AssemblyInformationalVersion=%version%
/p:ApplicationVersion=%version%
/p:InstallUrl=" https://mydomain.com/MyApp/"
- Use AzureSignToolClickOnce.exe to find and sign all the required executables and manifest files. Internally AzureSignTool is used to sign the executables and the mage code signs the manifest with Authenticode.
AzureSignToolClickOnce.exe ^
-p=bin\Release\app.publish^
-path=.^
-azure-key-vault-url=https://1234-vault.vault.azure.net/^
-azure-key-vault-client-id=1234^
-azure-key-vault-client-secret=1234^
-azure-key-vault-tenant-id=1234^
-azure-key-vault-certificate=MyGlobalSignCert^
-timestamp-sha2=http://timestamp.globalsign.com/?signature=sha2^
-timestamp-rfc3161=http://rfc3161timestamp.globalsign.com/advanced^
-description=MyApp^
Azure DevOps CI pipeline task
To run it from a Azure DevOps pipeline I included the AzureSignToolClickOnce.exe in my solution in a folder BuildTools to run it with an Azure Command line task.
..\..\..\..\BuildTools\AzureSignToolClickOnce\AzureSignToolClickOnce.exe ^
-path=.^
-azure-key-vault-url=$(Sign-azure-key-vault-url)^
-azure-key-vault-client-id=$(Sign-azure-key-vault-client-id)^
-azure-key-vault-client-secret=$(Sign-azure-key-vault-client-secret)^
-azure-key-vault-tenant-id=$(Sign-azure-key-vault-tenant-id)^
-azure-key-vault-certificate=$(Sign-azure-key-vault-certificate)^
-timestamp-sha2=$(Sign-timestamp-sha2)^
-timestamp-rfc3161=$(Sign-timestamp-rfc3161)^
-description=$(Sign-description)^
Make sure the working directory is set to folder with the output of the published files, for example MyApp\bin\Test\app.publish\. The secrets can be set using variables of the DevOps pipeline
This is the example output of the pipeline task run:
AzureSignToolClickOnce command start
Path: .
SignInAzureVault: .\MyApp.exe
SignInAzureVault: .\setup.exe
SignInAzureVault: .\Application Files\MyApp_1_1_001_0\MyApp.exe
Signing C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\mage.exe
-update ".\Application Files\MyApp_1_1_001_0\MyApp.exe.manifest" -a sha256RSA
Manifest signing .\Application Files\MyApp_1_1_001_0\MyApp.exe.manifest
Signing C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\mage.exe
-update ".\MyApp.application" -a sha256RSA -appm ".\Application Files\MyApp_1_1_001_0\MyApp.exe.manifest"
Manifest signing .\crm-test.application
Finishing: AzureSignToolClickOnce
The files in the publish folder are copied to the artifacts folder and are ready for release.
Source code
Below the C# .NET code for the service responsible for finding the files and calling the sign actions. The order of signing is important:
– The executable
– The .manifest file
– The nested clickonce/vsto file
– The top-level clickonce/vsto file
using Azure.Identity;
using Azure.Security.KeyVault.Certificates;
using AzureSign.Core;
using AzureSignToolClickOnce.Utils;
using RSAKeyVaultProvider;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace AzureSignToolClickOnce.Services
{
public class AzureSignToolService
{
private string _magetoolPath = @"C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\mage.exe";
public void Start(string description, string path, string timeStampUrl, string timeStampUrlRfc3161, string keyVaultUrl, string tenantId, string clientId, string clientSecret, string certName)
{
var tokenCredential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var client = new CertificateClient(vaultUri: new Uri(keyVaultUrl), credential: tokenCredential);
var cert = client.GetCertificate(certName).Value;
var certificate = new X509Certificate2(cert.Cer);
var keyIdentifier = cert.KeyId;
var rsa = RSAFactory.Create(tokenCredential, keyIdentifier, certificate);
// We need to be explicit about the order these files are signed in. The data files must be signed first
// Then the .manifest file
// Then the nested clickonce/vsto file
// finally the top-level clickonce/vsto file
var files = Directory.GetFiles(path, "*.*").ToList();
files.AddRange(Directory.GetFiles(path + @"\Application Files", "*.*", SearchOption.AllDirectories));
for (int i = 0; i < files.Count; i++)
{
string file = files[i];
files[i] = file.Replace(@"\\", @"\");
}
var filesToSign = new List();
var setupExe = files.Where(f => ".exe".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase));
filesToSign.AddRange(setupExe);
var manifestFile = files.SingleOrDefault(f => ".manifest".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(manifestFile))
{
Console.WriteLine("No manifest file found");
return;
}
// sign the exe files
SignInAzureVault(description, "", timeStampUrlRfc3161, certificate, rsa, filesToSign);
// look for the manifest file and sign that
var args = "-a sha256RSA";
var fileArgs = $@"-update ""{manifestFile}"" {args}";
if (!RunMageTool(fileArgs, manifestFile, rsa, certificate, timeStampUrl))
return;
// Now sign the inner vsto/clickonce file
// Order by desending length to put the inner one first
var clickOnceFilesToSign = files
.Where(f => ".vsto".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase) ||
".application".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase))
.Select(f => new { file = f, f.Length })
.OrderByDescending(f => f.Length)
.Select(f => f.file)
.ToList();
foreach (var f in clickOnceFilesToSign)
{
fileArgs = $@"-update ""{f}"" {args} -appm ""{manifestFile}""";
if (!RunMageTool(fileArgs, f, rsa, certificate, timeStampUrl))
{
throw new Exception($"Could not sign {f}");
}
}
}
private void SignInAzureVault(string description, string supportUrl, string timeStampUrlRfc3161, X509Certificate2 certificate, RSA rsaPrivateKey, List filesToSign)
{
var authenticodeKeyVaultSigner = new AuthenticodeKeyVaultSigner(rsaPrivateKey, certificate, HashAlgorithmName.SHA256,
new TimeStampConfiguration(timeStampUrlRfc3161, HashAlgorithmName.SHA256, TimeStampType.RFC3161));
foreach (var f in filesToSign)
{
Console.WriteLine($"SignInAzureVault: {f}");
authenticodeKeyVaultSigner.SignFile(f.AsSpan(), description.AsSpan(), supportUrl.AsSpan(), null);
}
}
private bool RunMageTool(string args, string inputFile, RSA rsa, X509Certificate2 publicCertificate, string timestampUrl)
{
var signtool = new Process
{
StartInfo =
{
FileName = _magetoolPath,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
Arguments = args
}
};
Console.WriteLine($"Signing {signtool.StartInfo.FileName} {args}");
signtool.Start();
signtool.WaitForExit();
if (signtool.ExitCode == 0)
{
Console.WriteLine($"Manifest signing {inputFile}");
ManifestSigner.SignFile(inputFile, rsa, publicCertificate, timestampUrl);
return true;
}
else
{
var output = signtool.StandardOutput.ReadToEnd();
var error = signtool.StandardError.ReadToEnd();
Debug.WriteLine($"Mage Out {output}");
if (!string.IsNullOrWhiteSpace(error))
{
Console.WriteLine($"Mage Err {error}");
}
}
Console.WriteLine($"Error: Signtool returned {signtool.ExitCode}");
return false;
}
}
}
Manifest signing
The generated xml manifest updated by mage.exe is loaded and signed.
using System;
using System.Deployment.Internal.CodeSigning;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Xml;
namespace AzureSignToolClickOnce.Utils
{
static class ManifestSigner
{
public static void SignFile(string path, RSA rsaPrivateKey, X509Certificate2 publicCertificate, string timestampUrl)
{
var useSha256 = true;
try
{
var manifestDom = new XmlDocument
{
PreserveWhitespace = true
};
manifestDom.Load(path);
var signedCmiManifest2 = new SignedCmiManifest2(manifestDom, useSha256);
var signer = !useSha256 || !(rsaPrivateKey is RSACryptoServiceProvider) ? new CmiManifestSigner2(rsaPrivateKey, publicCertificate, useSha256) : new CmiManifestSigner2(SignedCmiManifest2.GetFixedRSACryptoServiceProvider(rsaPrivateKey as RSACryptoServiceProvider, useSha256), publicCertificate, useSha256);
if (timestampUrl == null)
{
signedCmiManifest2.Sign(signer);
}
else
{
signedCmiManifest2.Sign(signer, timestampUrl);
}
manifestDom.Save(path);
}
catch (Exception ex)
{
switch (Marshal.GetHRForException(ex))
{
case -2147012889:
case -2147012867:
throw new ApplicationException("SecurityUtil.TimestampUrlNotFound", ex);
default:
throw new ApplicationException(ex.Message, ex);
}
}
}
}
}
Download
Finally, the link for the source code. Free to use on your own risk.