From 6a3584c3986aed2c9c00ea01dc5b73df0972c33b Mon Sep 17 00:00:00 2001 From: Cord Burmeister Date: Wed, 15 Feb 2023 11:25:29 +0100 Subject: [PATCH 1/8] Merged the required changes into the fork --- README.md | 8 + .../AzureKeyVaultSignConfigurationSet.cs | 1 + .../KeyVaultConfigurationDiscoverer.cs | 30 ++ src/AzureSignTool/SignCommand.cs | 409 ++++++++++++++++++ 4 files changed, 448 insertions(+) create mode 100644 src/AzureSignTool/SignCommand.cs diff --git a/README.md b/README.md index 41a392a..d83a119 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,14 @@ The single-file downloads do not require .NET to be installed on the system at a Azure, which will be used to generate an access token. This parameter is not required if an access token is supplied directly with the `--azure-key-vault-accesstoken` option or when using managed identities with `--azure-key-vault-managed-identity`. If this parameter is supplied, `--azure-key-vault-client-id` and `--azure-key-vault-tenant-id` must be supplied as well. +* `--azure-key-vault-client-auth-certificate` [short: `-kvac`, required: possibly]: This is the client secret used to authenticate to + Azure, which will be used to generate an access token. This parameter is not required if an access token is supplied + directly with the `--azure-key-vault-accesstoken` option or when using managed identities with `--azure-key-vault-managed-identity`. + If this parameter is supplied, `--azure-key-vault-client-id` and `--azure-key-vault-tenant-id` must be supplied as well and `--azure-key-vault-client-secret` must not be used. + Instead of using a secret a certificate is reuqired installed on the build machine executing the AzureSignTool and the public key must be known in the + Azure Key Valut. The Thumbprint of the certificate is used here. + This options allows more control which computer can sign and use the codesigning certificate, because it does not depend on the knowledge of the secret in the build pipeline. + * `--azure-key-vault-tenant-id` [short: `-kvt`, required: possibly]: This is the tenant id used to authenticate to Azure, which will be used to generate an access token. This parameter is not required if an access token is supplied directly with the `--azure-key-vault-accesstoken` option or when using managed identities with `--azure-key-vault-managed-identity`. If this parameter is supplied, `--azure-key-vault-client-id` and `--azure-key-vault-client-secret` must be supplied as well. diff --git a/src/AzureSignTool/AzureKeyVaultSignConfigurationSet.cs b/src/AzureSignTool/AzureKeyVaultSignConfigurationSet.cs index b38d869..092016d 100644 --- a/src/AzureSignTool/AzureKeyVaultSignConfigurationSet.cs +++ b/src/AzureSignTool/AzureKeyVaultSignConfigurationSet.cs @@ -13,5 +13,6 @@ public sealed class AzureKeyVaultSignConfigurationSet public string AzureKeyVaultCertificateVersion { get; init; } public string AzureAccessToken { get; init; } public string AzureAuthority { get; init; } + public string AzureCertificateThumbprint { get; set; } } } diff --git a/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs b/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs index 1c9f0af..90ae6ba 100644 --- a/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs +++ b/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs @@ -29,6 +29,12 @@ public async Task> Materialize(A { credential = new AccessTokenCredential(configuration.AzureAccessToken); } + else if (!string.IsNullOrWhiteSpace(configuration.AzureCertificateThumbprint)) + { + string certificateThumbPrint = configuration.AzureCertificateThumbprint; + X509Certificate2 clientCertificate = LoadCertificateByThumbprint(certificateThumbPrint, StoreLocation.CurrentUser); + credential = new ClientCertificateCredential(configuration.AzureTenantId, configuration.AzureClientId, clientCertificate); + } else { if (string.IsNullOrWhiteSpace(configuration.AzureAuthority)) @@ -82,5 +88,29 @@ public async Task> Materialize(A return new AzureKeyVaultMaterializedConfiguration(credential, certificate, keyId); } + + private X509Certificate2 LoadCertificateByThumbprint(string thumbprint, System.Security.Cryptography.X509Certificates.StoreLocation storeLocation) + { + X509Store certStore = new X509Store(StoreName.My, storeLocation); + certStore.Open(OpenFlags.ReadOnly); + try + { + X509Certificate2Collection certCollection = certStore.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (certCollection.Count > 0) + { + X509Certificate2 cert = certCollection[0]; + return cert; + } + else + { + _logger.LogTrace($"Could not find certificate with thumbprint {thumbprint} in store {storeLocation}"); + return null; + } + } + finally + { + certStore.Close(); + } + } } } diff --git a/src/AzureSignTool/SignCommand.cs b/src/AzureSignTool/SignCommand.cs new file mode 100644 index 0000000..090f208 --- /dev/null +++ b/src/AzureSignTool/SignCommand.cs @@ -0,0 +1,409 @@ +using AzureSign.Core; +using McMaster.Extensions.CommandLineUtils; +using McMaster.Extensions.CommandLineUtils.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; +using RSAKeyVaultProvider; + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; + +using static AzureSignTool.HRESULT; + +namespace AzureSignTool +{ + internal sealed class SignCommand + { + [Option("-kvu | --azure-key-vault-url", "The URL to an Azure Key Vault.", CommandOptionType.SingleValue), UriValidator, Required] + public string KeyVaultUri { get; set; } + + [Option("-kvi | --azure-key-vault-client-id", "The Client ID to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] + public (bool Present, string Value) KeyVaultClientId { get; set; } + + [Option("-kvs | --azure-key-vault-client-secret", "The Client Secret to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] + public (bool Present, string Value) KeyVaultClientSecret { get; set; } + + [Option("-kvac | --azure-key-vault-client-auth-certificate", "The Client certificate thumbprint to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] + public (bool Present, string Value) KeyVaultClientAuthCertificate { get; set; } + + [Option("-kvt | --azure-key-vault-tenant-id", "The Tenant Id to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] + public (bool Present, string Value) KeyVaultTenantId { get; set; } + + [Option("-kvc | --azure-key-vault-certificate", "The name of the certificate in Azure Key Vault.", CommandOptionType.SingleValue), Required] + public string KeyVaultCertificate { get; set; } + + [Option("-kva | --azure-key-vault-accesstoken", "The Access Token to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] + public (bool Present, string Value) KeyVaultAccessToken { get; set; } + + [Option("-kvm | --azure-key-vault-managed-identity", CommandOptionType.NoValue)] + public bool UseManagedIdentity { get; set; } + + [Option("-d | --description", "Provide a description of the signed content.", CommandOptionType.SingleValue)] + public string Description { get; set; } + + [Option("-du | --description-url", "Provide a URL with more information about the signed content.", CommandOptionType.SingleValue), UriValidator] + public string DescriptionUri { get; set; } + + [Option("-tr | --timestamp-rfc3161", "Specifies the RFC 3161 timestamp server's URL. If this option (or -t) is not specified, the signed file will not be timestamped.", CommandOptionType.SingleValue), UriValidator] + public (bool Present, string Uri) Rfc3161Timestamp { get; set; } + + [Option("-td | --timestamp-digest", "Used with the -tr switch to request a digest algorithm used by the RFC 3161 timestamp server.", CommandOptionType.SingleValue)] + [AllowedValues("sha1", "sha256", "sha384", "sha512", IgnoreCase = true)] + public HashAlgorithmName TimestampDigestAlgorithm { get; set; } = HashAlgorithmName.SHA256; + + [Option("-fd | --file-digest", "The digest algorithm to hash the file with.", CommandOptionType.SingleValue)] + [AllowedValues("sha1", "sha256", "sha384", "sha512", IgnoreCase = true)] + public HashAlgorithmName FileDigestAlgorithm { get; set; } = HashAlgorithmName.SHA256; + + [Option("-t | --timestamp-authenticode", "Specify the timestamp server's URL. If this option is not present, the signed file will not be timestamped.", CommandOptionType.SingleValue), UriValidator] + public (bool Present, string Uri) AuthenticodeTimestamp { get; set; } + + [Option("-ac | --additional-certificates", "Specify one or more certificates to include in the public certificate chain.", CommandOptionType.MultipleValue), FileExists] + public string[] AdditionalCertificates { get; set; } = Array.Empty(); + + [Option("-v | --verbose", "Include additional output.", CommandOptionType.NoValue)] + public bool Verbose { get; set; } + + [Option("-q | --quiet", "Do not print any output to the console.", CommandOptionType.NoValue)] + public bool Quiet { get; set; } + + [Option("-ph | --page-hashing", "Generate page hashes for executable files if supported.", CommandOptionType.NoValue)] + public bool PageHashing { get; set; } + + [Option("-nph | --no-page-hashing", "Suppress page hashes for executable files if supported.", CommandOptionType.NoValue)] + public bool NoPageHashing { get; set; } + + [Option("-coe | --continue-on-error", "Continue signing multiple files if an error occurs.", CommandOptionType.NoValue)] + public bool ContinueOnError { get; set; } + + [Option("-ifl | --input-file-list", "A path to a file that contains a list of files, one per line, to sign.", CommandOptionType.SingleValue), FileExists] + public string InputFileList { get; set; } + + [Option("-mdop | --max-degree-of-parallelism", "The maximum number of concurrent signing operations.", CommandOptionType.SingleValue), Range(-1, int.MaxValue)] + public int? MaxDegreeOfParallelism { get; set; } + + [Option("--colors", "Enable color output on the command line.", CommandOptionType.NoValue)] + public bool Colors { get; set; } = false; + + [Option("-s | --skip-signed", "Skip files that are already signed.", CommandOptionType.NoValue)] + public bool SkipSignedFiles { get; set; } = false; + + // We manually validate the file's existance with the --input-file-list. Don't validate here. + [Argument(0, "file", "The path to the file.")] + public string[] Files { get; set; } = Array.Empty(); + + private HashSet _allFiles; + public HashSet AllFiles + { + get + { + if (_allFiles == null) + { + _allFiles = new HashSet(Files); + if (!string.IsNullOrWhiteSpace(InputFileList)) + { + _allFiles.UnionWith(File.ReadLines(InputFileList).Where(s => !string.IsNullOrWhiteSpace(s))); + } + } + return _allFiles; + } + } + + public LogLevel LogLevel + { + get + { + if (Quiet) + { + return LogLevel.Critical; + } + else if (Verbose) + { + return LogLevel.Trace; + } + else + { + return LogLevel.Information; + } + } + } + + private ValidationResult OnValidate(ValidationContext context, CommandLineContext appContext) + { + if (PageHashing && NoPageHashing) + { + return new ValidationResult("Cannot use '--page-hashing' and '--no-page-hashing' options together.", new[] { nameof(NoPageHashing), nameof(PageHashing) }); + } + if (Quiet && Verbose) + { + return new ValidationResult("Cannot use '--quiet' and '--verbose' options together.", new[] { nameof(NoPageHashing), nameof(PageHashing) }); + } + if (!OneTrue(KeyVaultAccessToken.Present, KeyVaultClientId.Present, UseManagedIdentity)) + { + return new ValidationResult("One of '--azure-key-vault-accesstoken', '--azure-key-vault-client-id' or '--azure-key-vault-managed-identity' must be supplied.", new[] { nameof(KeyVaultAccessToken), nameof(KeyVaultClientId) }); + } + + if (Rfc3161Timestamp.Present && AuthenticodeTimestamp.Present) + { + return new ValidationResult("Cannot use '--timestamp-rfc3161' and '--timestamp-authenticode' options together.", new[] { nameof(Rfc3161Timestamp), nameof(AuthenticodeTimestamp) }); + } + + if (KeyVaultClientId.Present && !KeyVaultClientSecret.Present && !KeyVaultClientAuthCertificate.Present) + { + return new ValidationResult("Must supply '--azure-key-vault-client-secret' or '--azure-key-vault-client-auth-certificate' when using '--azure-key-vault-client-id'.", new[] { nameof(KeyVaultClientSecret) }); + } + + if (KeyVaultClientId.Present && !KeyVaultTenantId.Present) + { + return new ValidationResult("Must supply '--azure-key-vault-tenant-id' when using '--azure-key-vault-client-id'.", new[] { nameof(KeyVaultTenantId) }); + } + if (UseManagedIdentity && (KeyVaultAccessToken.Present || KeyVaultClientId.Present)) + { + return new ValidationResult("Cannot use '--azure-key-vault-managed-identity' and '--azure-key-vault-accesstoken' or '--azure-key-vault-client-id'", new[] { nameof(UseManagedIdentity) }); + } + if (AllFiles.Count == 0) + { + return new ValidationResult("At least one file must be specified to sign."); + } + foreach(var file in AllFiles) + { + if (!File.Exists(file)) + { + return new ValidationResult($"File '{file}' does not exist."); + } + } + return ValidationResult.Success; + } + + public int OnValidationError(ValidationResult result, CommandLineApplication command, IConsole console) + { + console.ForegroundColor = ConsoleColor.Red; + console.Error.WriteLine(result.ErrorMessage); + console.ResetColor(); + command.ShowHint(); + return E_INVALIDARG; + } + + private void ConfigureLogging(ILoggingBuilder builder) + { + builder.AddSimpleConsole(console => { + console.IncludeScopes = true; + console.ColorBehavior = Colors ? LoggerColorBehavior.Enabled : LoggerColorBehavior.Disabled; + }); + + builder.SetMinimumLevel(LogLevel); + } + + public async Task OnExecuteAsync(CommandLineApplication app, IConsole console) + { + using (var loggerFactory = LoggerFactory.Create(ConfigureLogging)) + { + var logger = loggerFactory.CreateLogger(); + X509Certificate2Collection certificates; + + switch (GetAdditionalCertificates(AdditionalCertificates, logger)) + { + case ErrorOr.Ok d: + certificates = d.Value; + break; + case ErrorOr.Err err: + logger.LogError(err.Error, err.Error.Message); + return E_INVALIDARG; + default: + logger.LogError("Failed to include additional certificates."); + return E_INVALIDARG; + } + + var configuration = new AzureKeyVaultSignConfigurationSet + { + AzureKeyVaultUrl = new Uri(KeyVaultUri), + AzureKeyVaultCertificateName = KeyVaultCertificate, + AzureClientId = KeyVaultClientId.Value, + AzureTenantId = KeyVaultTenantId.Value, + AzureCertificateThumbprint = KeyVaultClientAuthCertificate.Value, + AzureAccessToken = KeyVaultAccessToken.Value, + AzureClientSecret = KeyVaultClientSecret.Value, + ManagedIdentity = UseManagedIdentity, + }; + + TimeStampConfiguration timeStampConfiguration; + + if (Rfc3161Timestamp.Present) + { + timeStampConfiguration = new TimeStampConfiguration(Rfc3161Timestamp.Uri, TimestampDigestAlgorithm, TimeStampType.RFC3161); + } + else if (AuthenticodeTimestamp.Present) + { + logger.LogWarning("Authenticode timestamps should only be used for compatibility purposes. RFC3161 timestamps should be used."); + timeStampConfiguration = new TimeStampConfiguration(AuthenticodeTimestamp.Uri, default, TimeStampType.Authenticode); + } + else + { + logger.LogWarning("Signatures will not be timestamped. Signatures will become invalid when the signing certificate expires."); + timeStampConfiguration = TimeStampConfiguration.None; + } + bool? performPageHashing = null; + if (PageHashing) + { + performPageHashing = true; + } + if (NoPageHashing) + { + performPageHashing = false; + } + var configurationDiscoverer = new KeyVaultConfigurationDiscoverer(logger); + var materializedResult = await configurationDiscoverer.Materialize(configuration); + AzureKeyVaultMaterializedConfiguration materialized; + switch (materializedResult) + { + case ErrorOr.Ok ok: + materialized = ok.Value; + break; + default: + logger.LogError("Failed to get configuration from Azure Key Vault."); + return E_INVALIDARG; + } + int failed = 0, succeeded = 0; + var cancellationSource = new CancellationTokenSource(); + console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + cancellationSource.Cancel(); + logger.LogInformation("Cancelling signing operations."); + }; + var options = new ParallelOptions(); + if (MaxDegreeOfParallelism.HasValue) + { + options.MaxDegreeOfParallelism = MaxDegreeOfParallelism.Value; + } + logger.LogTrace("Creating context"); + + using (var keyVault = RSAFactory.Create(materialized.TokenCredential, materialized.KeyId, materialized.PublicCertificate)) + using (var signer = new AuthenticodeKeyVaultSigner(keyVault, materialized.PublicCertificate, FileDigestAlgorithm, timeStampConfiguration, certificates)) + { + Parallel.ForEach(AllFiles, options, () => (succeeded: 0, failed: 0), (filePath, pls, state) => + { + if (cancellationSource.IsCancellationRequested) + { + pls.Stop(); + } + if (pls.IsStopped) + { + return state; + } + using (logger.BeginScope("File: {Id}", filePath)) + { + logger.LogInformation("Signing file."); + + if (SkipSignedFiles && IsSigned(filePath)) + { + logger.LogInformation("Skipping already signed file."); + return (state.succeeded + 1, state.failed); + } + + var result = signer.SignFile(filePath, Description, DescriptionUri, performPageHashing, logger); + switch (result) + { + case COR_E_BADIMAGEFORMAT: + logger.LogError("The Publisher Identity in the AppxManifest.xml does not match the subject on the certificate."); + break; + case TRUST_E_SUBJECT_FORM_UNKNOWN: + logger.LogError("The file cannot be signed because it is not a recognized file type for signing or it is corrupt."); + break; + } + + if (result == S_OK) + { + logger.LogInformation("Signing completed successfully."); + return (state.succeeded + 1, state.failed); + } + else + { + logger.LogError($"Signing failed with error {result:X2}."); + if (!ContinueOnError || AllFiles.Count == 1) + { + logger.LogInformation("Stopping file signing."); + pls.Stop(); + } + + return (state.succeeded, state.failed + 1); + } + } + }, result => + { + Interlocked.Add(ref failed, result.failed); + Interlocked.Add(ref succeeded, result.succeeded); + }); + } + logger.LogInformation($"Successful operations: {succeeded}"); + logger.LogInformation($"Failed operations: {failed}"); + if (failed > 0 && succeeded == 0) + { + return E_ALL_FAILED; + } + else if (failed > 0) + { + return S_SOME_SUCCESS; + } + else + { + return S_OK; + } + } + } + + private static bool IsSigned(string filePath) + { + try + { + _ = X509Certificate.CreateFromSignedFile(filePath); + return true; + } + catch (CryptographicException) + { + return false; + } + } + + private static ErrorOr GetAdditionalCertificates(IEnumerable paths, ILogger logger) + { + var collection = new X509Certificate2Collection(); + try + { + foreach (var path in paths) + { + + var type = X509Certificate2.GetCertContentType(path); + switch (type) + { + case X509ContentType.Cert: + case X509ContentType.Authenticode: + case X509ContentType.SerializedCert: + var certificate = new X509Certificate2(path); + logger.LogTrace($"Including additional certificate {certificate.Thumbprint}."); + collection.Add(certificate); + break; + default: + return new Exception($"Specified file {path} is not a public valid certificate."); + } + } + } + catch (CryptographicException e) + { + logger.LogError(e, "An exception occurred while including an additional certificate."); + return e; + } + + return collection; + } + + private static bool OneTrue(params bool[] values) => values.Count(v => v) == 1; + } +} From 070f95c84fe0893e34eb55ae44c208556074761e Mon Sep 17 00:00:00 2001 From: "Kajan, Sebastian" Date: Mon, 2 Feb 2026 13:15:01 +0100 Subject: [PATCH 2/8] integrate parameter validation for '--azure-key-vault-client-auth-certificate' --- src/AzureSignTool/Program.cs | 8 +- src/AzureSignTool/SignCommand.cs | 409 ------------------------------- 2 files changed, 6 insertions(+), 411 deletions(-) delete mode 100644 src/AzureSignTool/SignCommand.cs diff --git a/src/AzureSignTool/Program.cs b/src/AzureSignTool/Program.cs index ecc397c..ae6cc80 100644 --- a/src/AzureSignTool/Program.cs +++ b/src/AzureSignTool/Program.cs @@ -68,6 +68,7 @@ internal sealed class SignCommand : Command internal string? KeyVaultUrl { get; set; } internal string? KeyVaultClientId { get; set; } internal string? KeyVaultClientSecret { get; set; } + internal string? KeyVaultClientAuthCertificate { get; set; } internal string? KeyVaultTenantId { get; set; } internal string? KeyVaultCertificate { get; set; } internal string? KeyVaultCertificateVersion { get; set; } @@ -162,6 +163,7 @@ public SignCommand() : base("sign", "Sign a file.", null) this.Add("kvu|azure-key-vault-url=", "The {URL} to an Azure Key Vault.", v => KeyVaultUrl = v); this.Add("kvi|azure-key-vault-client-id=", "The Client {ID} to authenticate to the Azure Key Vault.", v => KeyVaultClientId = v); this.Add("kvs|azure-key-vault-client-secret=", "The Client Secret to authenticate to the Azure Key Vault.", v => KeyVaultClientSecret = v); + this.Add("kvac|azure-key-vault-client-auth-certificate=", "The Client certificate thumbprint to authenticate to the Azure Key Vault.", v => KeyVaultClientSecret = v); this.Add("kvt|azure-key-vault-tenant-id=", "The Tenant Id to authenticate to the Azure Key Vault.", v => KeyVaultTenantId = v); this.Add("kvc|azure-key-vault-certificate=", "The name of the certificate in Azure Key Vault.", v => KeyVaultCertificate = v); this.Add("kvcv|azure-key-vault-certificate-version=", "The version of the certificate in Azure Key Vault to use. The current version of the certificate is used by default.", v => KeyVaultCertificateVersion = v); @@ -436,9 +438,11 @@ private bool ValidateArguments(CommandRunContext context) valid = false; } - if (KeyVaultClientId is not null && KeyVaultClientSecret is null) + //TODO: Certificate thumbprint authentication support + + if (KeyVaultClientId is not null && KeyVaultClientSecret is null && KeyVaultClientAuthCertificate is null) { - context.Error.WriteLine("Must supply '--azure-key-vault-client-secret' when using '--azure-key-vault-client-id'."); + context.Error.WriteLine("Must supply '--azure-key-vault-client-secret' or '--azure-key-vault-client-auth-certificate' when using '--azure-key-vault-client-id'."); valid = false; } diff --git a/src/AzureSignTool/SignCommand.cs b/src/AzureSignTool/SignCommand.cs deleted file mode 100644 index 090f208..0000000 --- a/src/AzureSignTool/SignCommand.cs +++ /dev/null @@ -1,409 +0,0 @@ -using AzureSign.Core; -using McMaster.Extensions.CommandLineUtils; -using McMaster.Extensions.CommandLineUtils.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Console; -using RSAKeyVaultProvider; - -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; - -using static AzureSignTool.HRESULT; - -namespace AzureSignTool -{ - internal sealed class SignCommand - { - [Option("-kvu | --azure-key-vault-url", "The URL to an Azure Key Vault.", CommandOptionType.SingleValue), UriValidator, Required] - public string KeyVaultUri { get; set; } - - [Option("-kvi | --azure-key-vault-client-id", "The Client ID to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] - public (bool Present, string Value) KeyVaultClientId { get; set; } - - [Option("-kvs | --azure-key-vault-client-secret", "The Client Secret to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] - public (bool Present, string Value) KeyVaultClientSecret { get; set; } - - [Option("-kvac | --azure-key-vault-client-auth-certificate", "The Client certificate thumbprint to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] - public (bool Present, string Value) KeyVaultClientAuthCertificate { get; set; } - - [Option("-kvt | --azure-key-vault-tenant-id", "The Tenant Id to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] - public (bool Present, string Value) KeyVaultTenantId { get; set; } - - [Option("-kvc | --azure-key-vault-certificate", "The name of the certificate in Azure Key Vault.", CommandOptionType.SingleValue), Required] - public string KeyVaultCertificate { get; set; } - - [Option("-kva | --azure-key-vault-accesstoken", "The Access Token to authenticate to the Azure Key Vault.", CommandOptionType.SingleValue)] - public (bool Present, string Value) KeyVaultAccessToken { get; set; } - - [Option("-kvm | --azure-key-vault-managed-identity", CommandOptionType.NoValue)] - public bool UseManagedIdentity { get; set; } - - [Option("-d | --description", "Provide a description of the signed content.", CommandOptionType.SingleValue)] - public string Description { get; set; } - - [Option("-du | --description-url", "Provide a URL with more information about the signed content.", CommandOptionType.SingleValue), UriValidator] - public string DescriptionUri { get; set; } - - [Option("-tr | --timestamp-rfc3161", "Specifies the RFC 3161 timestamp server's URL. If this option (or -t) is not specified, the signed file will not be timestamped.", CommandOptionType.SingleValue), UriValidator] - public (bool Present, string Uri) Rfc3161Timestamp { get; set; } - - [Option("-td | --timestamp-digest", "Used with the -tr switch to request a digest algorithm used by the RFC 3161 timestamp server.", CommandOptionType.SingleValue)] - [AllowedValues("sha1", "sha256", "sha384", "sha512", IgnoreCase = true)] - public HashAlgorithmName TimestampDigestAlgorithm { get; set; } = HashAlgorithmName.SHA256; - - [Option("-fd | --file-digest", "The digest algorithm to hash the file with.", CommandOptionType.SingleValue)] - [AllowedValues("sha1", "sha256", "sha384", "sha512", IgnoreCase = true)] - public HashAlgorithmName FileDigestAlgorithm { get; set; } = HashAlgorithmName.SHA256; - - [Option("-t | --timestamp-authenticode", "Specify the timestamp server's URL. If this option is not present, the signed file will not be timestamped.", CommandOptionType.SingleValue), UriValidator] - public (bool Present, string Uri) AuthenticodeTimestamp { get; set; } - - [Option("-ac | --additional-certificates", "Specify one or more certificates to include in the public certificate chain.", CommandOptionType.MultipleValue), FileExists] - public string[] AdditionalCertificates { get; set; } = Array.Empty(); - - [Option("-v | --verbose", "Include additional output.", CommandOptionType.NoValue)] - public bool Verbose { get; set; } - - [Option("-q | --quiet", "Do not print any output to the console.", CommandOptionType.NoValue)] - public bool Quiet { get; set; } - - [Option("-ph | --page-hashing", "Generate page hashes for executable files if supported.", CommandOptionType.NoValue)] - public bool PageHashing { get; set; } - - [Option("-nph | --no-page-hashing", "Suppress page hashes for executable files if supported.", CommandOptionType.NoValue)] - public bool NoPageHashing { get; set; } - - [Option("-coe | --continue-on-error", "Continue signing multiple files if an error occurs.", CommandOptionType.NoValue)] - public bool ContinueOnError { get; set; } - - [Option("-ifl | --input-file-list", "A path to a file that contains a list of files, one per line, to sign.", CommandOptionType.SingleValue), FileExists] - public string InputFileList { get; set; } - - [Option("-mdop | --max-degree-of-parallelism", "The maximum number of concurrent signing operations.", CommandOptionType.SingleValue), Range(-1, int.MaxValue)] - public int? MaxDegreeOfParallelism { get; set; } - - [Option("--colors", "Enable color output on the command line.", CommandOptionType.NoValue)] - public bool Colors { get; set; } = false; - - [Option("-s | --skip-signed", "Skip files that are already signed.", CommandOptionType.NoValue)] - public bool SkipSignedFiles { get; set; } = false; - - // We manually validate the file's existance with the --input-file-list. Don't validate here. - [Argument(0, "file", "The path to the file.")] - public string[] Files { get; set; } = Array.Empty(); - - private HashSet _allFiles; - public HashSet AllFiles - { - get - { - if (_allFiles == null) - { - _allFiles = new HashSet(Files); - if (!string.IsNullOrWhiteSpace(InputFileList)) - { - _allFiles.UnionWith(File.ReadLines(InputFileList).Where(s => !string.IsNullOrWhiteSpace(s))); - } - } - return _allFiles; - } - } - - public LogLevel LogLevel - { - get - { - if (Quiet) - { - return LogLevel.Critical; - } - else if (Verbose) - { - return LogLevel.Trace; - } - else - { - return LogLevel.Information; - } - } - } - - private ValidationResult OnValidate(ValidationContext context, CommandLineContext appContext) - { - if (PageHashing && NoPageHashing) - { - return new ValidationResult("Cannot use '--page-hashing' and '--no-page-hashing' options together.", new[] { nameof(NoPageHashing), nameof(PageHashing) }); - } - if (Quiet && Verbose) - { - return new ValidationResult("Cannot use '--quiet' and '--verbose' options together.", new[] { nameof(NoPageHashing), nameof(PageHashing) }); - } - if (!OneTrue(KeyVaultAccessToken.Present, KeyVaultClientId.Present, UseManagedIdentity)) - { - return new ValidationResult("One of '--azure-key-vault-accesstoken', '--azure-key-vault-client-id' or '--azure-key-vault-managed-identity' must be supplied.", new[] { nameof(KeyVaultAccessToken), nameof(KeyVaultClientId) }); - } - - if (Rfc3161Timestamp.Present && AuthenticodeTimestamp.Present) - { - return new ValidationResult("Cannot use '--timestamp-rfc3161' and '--timestamp-authenticode' options together.", new[] { nameof(Rfc3161Timestamp), nameof(AuthenticodeTimestamp) }); - } - - if (KeyVaultClientId.Present && !KeyVaultClientSecret.Present && !KeyVaultClientAuthCertificate.Present) - { - return new ValidationResult("Must supply '--azure-key-vault-client-secret' or '--azure-key-vault-client-auth-certificate' when using '--azure-key-vault-client-id'.", new[] { nameof(KeyVaultClientSecret) }); - } - - if (KeyVaultClientId.Present && !KeyVaultTenantId.Present) - { - return new ValidationResult("Must supply '--azure-key-vault-tenant-id' when using '--azure-key-vault-client-id'.", new[] { nameof(KeyVaultTenantId) }); - } - if (UseManagedIdentity && (KeyVaultAccessToken.Present || KeyVaultClientId.Present)) - { - return new ValidationResult("Cannot use '--azure-key-vault-managed-identity' and '--azure-key-vault-accesstoken' or '--azure-key-vault-client-id'", new[] { nameof(UseManagedIdentity) }); - } - if (AllFiles.Count == 0) - { - return new ValidationResult("At least one file must be specified to sign."); - } - foreach(var file in AllFiles) - { - if (!File.Exists(file)) - { - return new ValidationResult($"File '{file}' does not exist."); - } - } - return ValidationResult.Success; - } - - public int OnValidationError(ValidationResult result, CommandLineApplication command, IConsole console) - { - console.ForegroundColor = ConsoleColor.Red; - console.Error.WriteLine(result.ErrorMessage); - console.ResetColor(); - command.ShowHint(); - return E_INVALIDARG; - } - - private void ConfigureLogging(ILoggingBuilder builder) - { - builder.AddSimpleConsole(console => { - console.IncludeScopes = true; - console.ColorBehavior = Colors ? LoggerColorBehavior.Enabled : LoggerColorBehavior.Disabled; - }); - - builder.SetMinimumLevel(LogLevel); - } - - public async Task OnExecuteAsync(CommandLineApplication app, IConsole console) - { - using (var loggerFactory = LoggerFactory.Create(ConfigureLogging)) - { - var logger = loggerFactory.CreateLogger(); - X509Certificate2Collection certificates; - - switch (GetAdditionalCertificates(AdditionalCertificates, logger)) - { - case ErrorOr.Ok d: - certificates = d.Value; - break; - case ErrorOr.Err err: - logger.LogError(err.Error, err.Error.Message); - return E_INVALIDARG; - default: - logger.LogError("Failed to include additional certificates."); - return E_INVALIDARG; - } - - var configuration = new AzureKeyVaultSignConfigurationSet - { - AzureKeyVaultUrl = new Uri(KeyVaultUri), - AzureKeyVaultCertificateName = KeyVaultCertificate, - AzureClientId = KeyVaultClientId.Value, - AzureTenantId = KeyVaultTenantId.Value, - AzureCertificateThumbprint = KeyVaultClientAuthCertificate.Value, - AzureAccessToken = KeyVaultAccessToken.Value, - AzureClientSecret = KeyVaultClientSecret.Value, - ManagedIdentity = UseManagedIdentity, - }; - - TimeStampConfiguration timeStampConfiguration; - - if (Rfc3161Timestamp.Present) - { - timeStampConfiguration = new TimeStampConfiguration(Rfc3161Timestamp.Uri, TimestampDigestAlgorithm, TimeStampType.RFC3161); - } - else if (AuthenticodeTimestamp.Present) - { - logger.LogWarning("Authenticode timestamps should only be used for compatibility purposes. RFC3161 timestamps should be used."); - timeStampConfiguration = new TimeStampConfiguration(AuthenticodeTimestamp.Uri, default, TimeStampType.Authenticode); - } - else - { - logger.LogWarning("Signatures will not be timestamped. Signatures will become invalid when the signing certificate expires."); - timeStampConfiguration = TimeStampConfiguration.None; - } - bool? performPageHashing = null; - if (PageHashing) - { - performPageHashing = true; - } - if (NoPageHashing) - { - performPageHashing = false; - } - var configurationDiscoverer = new KeyVaultConfigurationDiscoverer(logger); - var materializedResult = await configurationDiscoverer.Materialize(configuration); - AzureKeyVaultMaterializedConfiguration materialized; - switch (materializedResult) - { - case ErrorOr.Ok ok: - materialized = ok.Value; - break; - default: - logger.LogError("Failed to get configuration from Azure Key Vault."); - return E_INVALIDARG; - } - int failed = 0, succeeded = 0; - var cancellationSource = new CancellationTokenSource(); - console.CancelKeyPress += (_, e) => - { - e.Cancel = true; - cancellationSource.Cancel(); - logger.LogInformation("Cancelling signing operations."); - }; - var options = new ParallelOptions(); - if (MaxDegreeOfParallelism.HasValue) - { - options.MaxDegreeOfParallelism = MaxDegreeOfParallelism.Value; - } - logger.LogTrace("Creating context"); - - using (var keyVault = RSAFactory.Create(materialized.TokenCredential, materialized.KeyId, materialized.PublicCertificate)) - using (var signer = new AuthenticodeKeyVaultSigner(keyVault, materialized.PublicCertificate, FileDigestAlgorithm, timeStampConfiguration, certificates)) - { - Parallel.ForEach(AllFiles, options, () => (succeeded: 0, failed: 0), (filePath, pls, state) => - { - if (cancellationSource.IsCancellationRequested) - { - pls.Stop(); - } - if (pls.IsStopped) - { - return state; - } - using (logger.BeginScope("File: {Id}", filePath)) - { - logger.LogInformation("Signing file."); - - if (SkipSignedFiles && IsSigned(filePath)) - { - logger.LogInformation("Skipping already signed file."); - return (state.succeeded + 1, state.failed); - } - - var result = signer.SignFile(filePath, Description, DescriptionUri, performPageHashing, logger); - switch (result) - { - case COR_E_BADIMAGEFORMAT: - logger.LogError("The Publisher Identity in the AppxManifest.xml does not match the subject on the certificate."); - break; - case TRUST_E_SUBJECT_FORM_UNKNOWN: - logger.LogError("The file cannot be signed because it is not a recognized file type for signing or it is corrupt."); - break; - } - - if (result == S_OK) - { - logger.LogInformation("Signing completed successfully."); - return (state.succeeded + 1, state.failed); - } - else - { - logger.LogError($"Signing failed with error {result:X2}."); - if (!ContinueOnError || AllFiles.Count == 1) - { - logger.LogInformation("Stopping file signing."); - pls.Stop(); - } - - return (state.succeeded, state.failed + 1); - } - } - }, result => - { - Interlocked.Add(ref failed, result.failed); - Interlocked.Add(ref succeeded, result.succeeded); - }); - } - logger.LogInformation($"Successful operations: {succeeded}"); - logger.LogInformation($"Failed operations: {failed}"); - if (failed > 0 && succeeded == 0) - { - return E_ALL_FAILED; - } - else if (failed > 0) - { - return S_SOME_SUCCESS; - } - else - { - return S_OK; - } - } - } - - private static bool IsSigned(string filePath) - { - try - { - _ = X509Certificate.CreateFromSignedFile(filePath); - return true; - } - catch (CryptographicException) - { - return false; - } - } - - private static ErrorOr GetAdditionalCertificates(IEnumerable paths, ILogger logger) - { - var collection = new X509Certificate2Collection(); - try - { - foreach (var path in paths) - { - - var type = X509Certificate2.GetCertContentType(path); - switch (type) - { - case X509ContentType.Cert: - case X509ContentType.Authenticode: - case X509ContentType.SerializedCert: - var certificate = new X509Certificate2(path); - logger.LogTrace($"Including additional certificate {certificate.Thumbprint}."); - collection.Add(certificate); - break; - default: - return new Exception($"Specified file {path} is not a public valid certificate."); - } - } - } - catch (CryptographicException e) - { - logger.LogError(e, "An exception occurred while including an additional certificate."); - return e; - } - - return collection; - } - - private static bool OneTrue(params bool[] values) => values.Count(v => v) == 1; - } -} From 209ceea025b77c0fc9110019111e60e33c7ae8ea Mon Sep 17 00:00:00 2001 From: "Kajan, Sebastian" Date: Mon, 2 Feb 2026 13:39:34 +0100 Subject: [PATCH 3/8] fix constructor and correctly give the parameter KeyVaultClientAuthCertificate --- src/AzureSignTool/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AzureSignTool/Program.cs b/src/AzureSignTool/Program.cs index ae6cc80..cf766d4 100644 --- a/src/AzureSignTool/Program.cs +++ b/src/AzureSignTool/Program.cs @@ -163,7 +163,7 @@ public SignCommand() : base("sign", "Sign a file.", null) this.Add("kvu|azure-key-vault-url=", "The {URL} to an Azure Key Vault.", v => KeyVaultUrl = v); this.Add("kvi|azure-key-vault-client-id=", "The Client {ID} to authenticate to the Azure Key Vault.", v => KeyVaultClientId = v); this.Add("kvs|azure-key-vault-client-secret=", "The Client Secret to authenticate to the Azure Key Vault.", v => KeyVaultClientSecret = v); - this.Add("kvac|azure-key-vault-client-auth-certificate=", "The Client certificate thumbprint to authenticate to the Azure Key Vault.", v => KeyVaultClientSecret = v); + this.Add("kvac|azure-key-vault-client-auth-certificate=", "The Client certificate thumbprint to authenticate to the Azure Key Vault.", v => KeyVaultClientAuthCertificate = v); this.Add("kvt|azure-key-vault-tenant-id=", "The Tenant Id to authenticate to the Azure Key Vault.", v => KeyVaultTenantId = v); this.Add("kvc|azure-key-vault-certificate=", "The name of the certificate in Azure Key Vault.", v => KeyVaultCertificate = v); this.Add("kvcv|azure-key-vault-certificate-version=", "The version of the certificate in Azure Key Vault to use. The current version of the certificate is used by default.", v => KeyVaultCertificateVersion = v); @@ -236,6 +236,7 @@ private async ValueTask RunSign() AzureClientSecret = KeyVaultClientSecret, ManagedIdentity = UseManagedIdentity, AzureAuthority = AzureAuthority, + AzureCertificateThumbprint = KeyVaultClientAuthCertificate }; TimeStampConfiguration timeStampConfiguration; From 74a1c4bca96170c987389056cd26e75082e4f0e6 Mon Sep 17 00:00:00 2001 From: "Kajan, Sebastian" Date: Fri, 6 Feb 2026 09:38:17 +0100 Subject: [PATCH 4/8] improcve parameter description --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index d83a119..be1416b 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,7 @@ The single-file downloads do not require .NET to be installed on the system at a Azure, which will be used to generate an access token. This parameter is not required if an access token is supplied directly with the `--azure-key-vault-accesstoken` option or when using managed identities with `--azure-key-vault-managed-identity`. If this parameter is supplied, `--azure-key-vault-client-id` and `--azure-key-vault-tenant-id` must be supplied as well. -* `--azure-key-vault-client-auth-certificate` [short: `-kvac`, required: possibly]: This is the client secret used to authenticate to - Azure, which will be used to generate an access token. This parameter is not required if an access token is supplied +* `--azure-key-vault-client-auth-certificate` [short: `-kvac`, required: possibly]: This defines the thumbprint of a client authentication certificate, which is used to generate an access token for authentication to Azure. This parameter is not required if an access token is supplied directly with the `--azure-key-vault-accesstoken` option or when using managed identities with `--azure-key-vault-managed-identity`. If this parameter is supplied, `--azure-key-vault-client-id` and `--azure-key-vault-tenant-id` must be supplied as well and `--azure-key-vault-client-secret` must not be used. Instead of using a secret a certificate is reuqired installed on the build machine executing the AzureSignTool and the public key must be known in the From d2f2eac00a2c0813b61fbbad639f9aa706537585 Mon Sep 17 00:00:00 2001 From: Cord Burmeister Date: Mon, 2 Mar 2026 12:47:30 +0100 Subject: [PATCH 5/8] Remove TODO for certificate thumbprint authentication Removed TODO comment regarding certificate thumbprint authentication support. --- src/AzureSignTool/Program.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/AzureSignTool/Program.cs b/src/AzureSignTool/Program.cs index cf766d4..d4739c8 100644 --- a/src/AzureSignTool/Program.cs +++ b/src/AzureSignTool/Program.cs @@ -439,8 +439,6 @@ private bool ValidateArguments(CommandRunContext context) valid = false; } - //TODO: Certificate thumbprint authentication support - if (KeyVaultClientId is not null && KeyVaultClientSecret is null && KeyVaultClientAuthCertificate is null) { context.Error.WriteLine("Must supply '--azure-key-vault-client-secret' or '--azure-key-vault-client-auth-certificate' when using '--azure-key-vault-client-id'."); From 3cb4100948c0536e14e3a095a4bfe3e2f2dc6765 Mon Sep 17 00:00:00 2001 From: Cord Burmeister Date: Thu, 5 Mar 2026 16:40:13 +0100 Subject: [PATCH 6/8] Update src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs Co-authored-by: Kevin Jones --- src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs b/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs index 90ae6ba..2a76e1b 100644 --- a/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs +++ b/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs @@ -107,6 +107,10 @@ private X509Certificate2 LoadCertificateByThumbprint(string thumbprint, System.S return null; } } + catch (CryptographicException) + { + return null; + } finally { certStore.Close(); From 333f86691908601b6ac032d7c0beb61695dbc39b Mon Sep 17 00:00:00 2001 From: Cord Burmeister Date: Fri, 6 Mar 2026 08:15:39 +0100 Subject: [PATCH 7/8] Update the pull request --- .../KeyVaultConfigurationDiscoverer.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs b/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs index 90ae6ba..1681ecf 100644 --- a/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs +++ b/src/AzureSignTool/KeyVaultConfigurationDiscoverer.cs @@ -1,14 +1,17 @@ +#nullable enable using Azure.Core; using Azure.Identity; using Azure.Security.KeyVault.Certificates; using Microsoft.Extensions.Logging; using System; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; namespace AzureSignTool { + internal class KeyVaultConfigurationDiscoverer { private readonly ILogger _logger; @@ -32,7 +35,9 @@ public async Task> Materialize(A else if (!string.IsNullOrWhiteSpace(configuration.AzureCertificateThumbprint)) { string certificateThumbPrint = configuration.AzureCertificateThumbprint; - X509Certificate2 clientCertificate = LoadCertificateByThumbprint(certificateThumbPrint, StoreLocation.CurrentUser); + X509Certificate2? clientCertificate = LoadCertificateByThumbprint(certificateThumbPrint, StoreLocation.CurrentUser); + clientCertificate ??= LoadCertificateByThumbprint(certificateThumbPrint, StoreLocation.LocalMachine); + credential = new ClientCertificateCredential(configuration.AzureTenantId, configuration.AzureClientId, clientCertificate); } else @@ -89,7 +94,7 @@ public async Task> Materialize(A return new AzureKeyVaultMaterializedConfiguration(credential, certificate, keyId); } - private X509Certificate2 LoadCertificateByThumbprint(string thumbprint, System.Security.Cryptography.X509Certificates.StoreLocation storeLocation) + private X509Certificate2? LoadCertificateByThumbprint(string thumbprint, StoreLocation storeLocation) { X509Store certStore = new X509Store(StoreName.My, storeLocation); certStore.Open(OpenFlags.ReadOnly); @@ -107,6 +112,10 @@ private X509Certificate2 LoadCertificateByThumbprint(string thumbprint, System.S return null; } } + catch (CryptographicException) + { + return null; + } finally { certStore.Close(); From 04f6b7aa64ee6eb254eaaf72321993bada240792 Mon Sep 17 00:00:00 2001 From: Cord Burmeister Date: Tue, 17 Mar 2026 08:42:23 +0100 Subject: [PATCH 8/8] Validate client auth cert as 40-char hex in CLI options Added validation to ensure --azure-key-vault-client-auth-certificate is a valid 40-character hexadecimal string when used with --azure-key-vault-client-id. Introduced IsValidHex method to perform this check and display an error if validation fails. --- src/AzureSignTool/Program.cs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/AzureSignTool/Program.cs b/src/AzureSignTool/Program.cs index d4739c8..d4f8b82 100644 --- a/src/AzureSignTool/Program.cs +++ b/src/AzureSignTool/Program.cs @@ -445,6 +445,12 @@ private bool ValidateArguments(CommandRunContext context) valid = false; } + if (KeyVaultClientId is not null && KeyVaultClientAuthCertificate is not null && !IsValidHex(KeyVaultClientAuthCertificate)) + { + context.Error.WriteLine("The value for '--azure-key-vault-client-auth-certificate' must be a valid hexadecimal string when using '--azure-key-vault-client-id'."); + valid = false; + } + if (KeyVaultClientId is not null && KeyVaultTenantId is null) { context.Error.WriteLine("Must supply '--azure-key-vault-tenant-id' when using '--azure-key-vault-client-id'."); @@ -510,6 +516,29 @@ private bool ValidateArguments(CommandRunContext context) return valid; } + static bool IsValidHex(string input) + { + if (input is not { Length: 40 }) + { + return false; + } + + foreach (char c in input) + { + switch (c) + { + case >= 'a' and <= 'f': + case >= 'A' and <= 'F': + case >= '0' and <= '9': + continue; + default: + return false; + } + } + + return true; + } + private static bool ValidateHashAlgorithm(CommandRunContext context, string? input, string optionName) { if (input is null)