From 5cb7c30d2afbdbe8b2e07bbbd622f51f86a8b775 Mon Sep 17 00:00:00 2001 From: Jaxel Rojas Lopez Date: Wed, 8 Jul 2026 11:44:05 -0400 Subject: [PATCH 1/2] Pass Artifact Signing intermediates to Azuresign.Core for chain builder --- .../DataFormatSigners/AzureSignToolSigner.cs | 18 +++++++------ src/Sign.Core/ICertificateProvider.cs | 7 ++++++ .../ArtifactSigningService.cs | 25 +++++++++++++++---- .../RSAArtifactSigning.cs | 9 ++++++- .../RSATrustedSigningTests.cs | 2 ++ 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/Sign.Core/DataFormatSigners/AzureSignToolSigner.cs b/src/Sign.Core/DataFormatSigners/AzureSignToolSigner.cs index d881bba0..15e3f80c 100644 --- a/src/Sign.Core/DataFormatSigners/AzureSignToolSigner.cs +++ b/src/Sign.Core/DataFormatSigners/AzureSignToolSigner.cs @@ -2,12 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE.txt file in the project root for more information. +using AzureSign.Core; +using Microsoft.Extensions.Logging; using System.Globalization; using System.Runtime.ExceptionServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using AzureSign.Core; -using Microsoft.Extensions.Logging; namespace Sign.Core { @@ -119,12 +119,16 @@ public async Task SignAsync(IEnumerable files, SignOptions options) using (X509Certificate2 certificate = await _certificateProvider.GetCertificateAsync()) using (RSA rsa = await _signatureAlgorithmProvider.GetRsaAsync()) - using (AuthenticodeKeyVaultSigner signer = new( - rsa, - certificate, - options.FileHashAlgorithm, - timestampConfiguration)) { + X509Certificate2Collection additionalCertificates = await _certificateProvider.GetAdditionalCertificatesAsync(); + + using AuthenticodeKeyVaultSigner signer = new( + rsa, + certificate, + options.FileHashAlgorithm, + timestampConfiguration, + additionalCertificates); + // Partition files: STA-required files (.js, .vbs) are signed sequentially // to avoid blocking ThreadPool threads (each STA call uses thread.Join()). // Non-STA files are signed in parallel as before. diff --git a/src/Sign.Core/ICertificateProvider.cs b/src/Sign.Core/ICertificateProvider.cs index 25723a89..93d6c02d 100644 --- a/src/Sign.Core/ICertificateProvider.cs +++ b/src/Sign.Core/ICertificateProvider.cs @@ -16,5 +16,12 @@ internal interface ICertificateProvider /// /// An certificate acquired from the initialized certificate service. Task GetCertificateAsync(CancellationToken cancellationToken = default); + + /// + /// Acquires additional certificates (e.g. intermediates) used to build the chain for the signing certificate. + /// Providers that do not have a chain to expose return an empty collection. + /// + Task GetAdditionalCertificatesAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new X509Certificate2Collection()); } } \ No newline at end of file diff --git a/src/Sign.SignatureProviders.ArtifactSigning/ArtifactSigningService.cs b/src/Sign.SignatureProviders.ArtifactSigning/ArtifactSigningService.cs index e3947857..17d54f86 100644 --- a/src/Sign.SignatureProviders.ArtifactSigning/ArtifactSigningService.cs +++ b/src/Sign.SignatureProviders.ArtifactSigning/ArtifactSigningService.cs @@ -2,13 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE.txt file in the project root for more information. -using System.Diagnostics; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; using Azure; using Azure.CodeSigning; using Microsoft.Extensions.Logging; using Sign.Core; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; namespace Sign.SignatureProviders.ArtifactSigning { @@ -20,6 +20,7 @@ internal sealed class ArtifactSigningService : ISignatureAlgorithmProvider, ICer private readonly ILogger _logger; private readonly SemaphoreSlim _mutex = new(1); private X509Certificate2? _certificate; + private X509Certificate2Collection? _additionalCertificates; public ArtifactSigningService( CertificateProfileClient certificateProfileClient, @@ -72,8 +73,12 @@ public async Task GetCertificateAsync(CancellationToken cancel X509Certificate2Collection collection = []; collection.Import(rawData); - // This should contain the certificate chain in root->leaf order. - _certificate = collection[collection.Count - 1]; + // Find the leaf: the cert whose Subject is not any other cert's Issuer. + IEnumerable issuers = collection.Cast().Select(x => x.Issuer); + _certificate = collection.Cast().FirstOrDefault(x => !issuers.Contains(x.Subject)) + ?? throw new InvalidOperationException("Unable to locate leaf certificate"); + + _additionalCertificates = [.. collection.Cast().Where(x => x != _certificate).ToArray()]; _logger.LogTrace(Resources.FetchedCertificate, stopwatch.Elapsed.TotalMilliseconds); //print the certificate info @@ -95,5 +100,15 @@ public async Task GetRsaAsync(CancellationToken cancellationToken) RSA rsaPublicKey = certificate.GetRSAPublicKey()!; return new RSAArtifactSigning(_client, _accountName, _certificateProfileName, rsaPublicKey); } + + public async Task GetAdditionalCertificatesAsync(CancellationToken cancellationToken = default) + { + if (_additionalCertificates is null) + { + await GetCertificateAsync(cancellationToken); + } + + return _additionalCertificates ?? []; + } } } diff --git a/src/Sign.SignatureProviders.ArtifactSigning/RSAArtifactSigning.cs b/src/Sign.SignatureProviders.ArtifactSigning/RSAArtifactSigning.cs index 3d4212da..5426cb68 100644 --- a/src/Sign.SignatureProviders.ArtifactSigning/RSAArtifactSigning.cs +++ b/src/Sign.SignatureProviders.ArtifactSigning/RSAArtifactSigning.cs @@ -62,7 +62,14 @@ public override byte[] SignHash(byte[] hash, HashAlgorithmName hashAlgorithm, RS SignRequest request = new(signatureAlgorithm, hash); CertificateProfileSignOperation operation = _client.StartSign(_accountName, _certificateProfileName, request); Response response = operation.WaitForCompletion(); - return response.Value.Signature; + byte[] signature = response.Value.Signature; + + if (!_rsaPublicKey.VerifyHash(hash, signature, hashAlgorithm, padding)) + { + throw new CryptographicException("Invalid signature"); + } + + return signature; } public override bool VerifyHash(byte[] hash, byte[] signature, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) diff --git a/test/Sign.SignatureProviders.ArtifactSigning.Test/RSATrustedSigningTests.cs b/test/Sign.SignatureProviders.ArtifactSigning.Test/RSATrustedSigningTests.cs index 3c163b1e..54ea1208 100644 --- a/test/Sign.SignatureProviders.ArtifactSigning.Test/RSATrustedSigningTests.cs +++ b/test/Sign.SignatureProviders.ArtifactSigning.Test/RSATrustedSigningTests.cs @@ -161,6 +161,8 @@ public void SignHash_UsesClient(int hashLength, string paddingName, string expec _client.Setup(_ => _.StartSign(AccountName, CertificateProfileName, It.IsAny(), null, null, null, default)) .Returns(operation.Object); + _rsaPublicKey.Setup(_ => _.VerifyHash(hash, signature, hashAlgorithmName, padding)).Returns(true); + var result = rsa.SignHash(hash, hashAlgorithmName, padding); Assert.Same(signature, result); From 6b07ad0175a87a808c00c2be6432aa59b5712c5c Mon Sep 17 00:00:00 2001 From: Jaxel Rojas Lopez Date: Wed, 8 Jul 2026 14:10:38 -0400 Subject: [PATCH 2/2] tests: addition of unit tests retrieving the certificates --- ...=> ArtifactSigningServiceProviderTests.cs} | 0 .../ArtifactSigningServiceTests.cs | 162 ++++++++++++++++++ ...ingTests.cs => RSAArtifactSigningTests.cs} | 4 +- .../TrustedSigningServiceTests.cs | 73 -------- 4 files changed, 164 insertions(+), 75 deletions(-) rename test/Sign.SignatureProviders.ArtifactSigning.Test/{TrustedSigningServiceProviderTests.cs => ArtifactSigningServiceProviderTests.cs} (100%) create mode 100644 test/Sign.SignatureProviders.ArtifactSigning.Test/ArtifactSigningServiceTests.cs rename test/Sign.SignatureProviders.ArtifactSigning.Test/{RSATrustedSigningTests.cs => RSAArtifactSigningTests.cs} (99%) delete mode 100644 test/Sign.SignatureProviders.ArtifactSigning.Test/TrustedSigningServiceTests.cs diff --git a/test/Sign.SignatureProviders.ArtifactSigning.Test/TrustedSigningServiceProviderTests.cs b/test/Sign.SignatureProviders.ArtifactSigning.Test/ArtifactSigningServiceProviderTests.cs similarity index 100% rename from test/Sign.SignatureProviders.ArtifactSigning.Test/TrustedSigningServiceProviderTests.cs rename to test/Sign.SignatureProviders.ArtifactSigning.Test/ArtifactSigningServiceProviderTests.cs diff --git a/test/Sign.SignatureProviders.ArtifactSigning.Test/ArtifactSigningServiceTests.cs b/test/Sign.SignatureProviders.ArtifactSigning.Test/ArtifactSigningServiceTests.cs new file mode 100644 index 00000000..ede7fac1 --- /dev/null +++ b/test/Sign.SignatureProviders.ArtifactSigning.Test/ArtifactSigningServiceTests.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE.txt file in the project root for more information. + +using Azure; +using Azure.CodeSigning; +using Microsoft.Extensions.Logging; +using Moq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Sign.SignatureProviders.ArtifactSigning.Test +{ + public class ArtifactSigningServiceTests + { + private static readonly CertificateProfileClient CertificateProfileClient = Mock.Of(); + private const string AccountName = "a"; + private const string CertificateProfileName = "b"; + private static readonly ILogger Logger = Mock.Of>(); + + [Fact] + public void Constructor_WhenCertificateProfileClientIsNull_Throws() + { + ArgumentNullException exception = Assert.Throws( + () => new ArtifactSigningService(certificateProfileClient: null!, AccountName, CertificateProfileName, Logger)); + + Assert.Equal("certificateProfileClient", exception.ParamName); + } + + [Fact] + public void Constructor_WhenAccountNameIsNull_Throws() + { + ArgumentNullException exception = Assert.Throws( + () => new ArtifactSigningService(CertificateProfileClient, accountName: null!, CertificateProfileName, Logger)); + + Assert.Equal("accountName", exception.ParamName); + } + + [Fact] + public void Constructor_WhenAccountNameIsEmpty_Throws() + { + ArgumentException exception = Assert.Throws( + () => new ArtifactSigningService(CertificateProfileClient, accountName: string.Empty, CertificateProfileName, Logger)); + + Assert.Equal("accountName", exception.ParamName); + } + + [Fact] + public void Constructor_WhenCertificateProfileNameIsNull_Throws() + { + ArgumentNullException exception = Assert.Throws( + () => new ArtifactSigningService(CertificateProfileClient, AccountName, certificateProfileName: null!, Logger)); + + Assert.Equal("certificateProfileName", exception.ParamName); + } + + [Fact] + public void Constructor_WhenCertificateProfileNameIsEmpty_Throws() + { + ArgumentException exception = Assert.Throws( + () => new ArtifactSigningService(CertificateProfileClient, AccountName, certificateProfileName: string.Empty, Logger)); + + Assert.Equal("certificateProfileName", exception.ParamName); + } + + [Fact] + public void Constructor_WhenLoggerIsNull_Throws() + { + ArgumentNullException exception = Assert.Throws( + () => new ArtifactSigningService(CertificateProfileClient, AccountName, CertificateProfileName, logger: null!)); + + Assert.Equal("logger", exception.ParamName); + } + + [Fact] + public async Task GetCertificateAsync_WhenChainReturned_ReturnsLeafCertificate() + { + var (root, intermediate, leaf) = BuildCertificateChain(); + using (root) + using (intermediate) + using (leaf) + { + Mock client = CreateClientReturningChain(root, intermediate, leaf); + + using ArtifactSigningService service = new(client.Object, AccountName, CertificateProfileName, Logger); + + using X509Certificate2 result = await service.GetCertificateAsync(CancellationToken.None); + + Assert.Equal(leaf.Thumbprint, result.Thumbprint); + } + } + + [Fact] + public async Task GetAdditionalCertificatesAsync_WhenChainReturned_ReturnsNonLeafCertificates() + { + var (root, intermediate, leaf) = BuildCertificateChain(); + using (root) + using (intermediate) + using (leaf) + { + Mock client = CreateClientReturningChain(root, intermediate, leaf); + + using ArtifactSigningService service = new(client.Object, AccountName, CertificateProfileName, Logger); + + X509Certificate2Collection result = await service.GetAdditionalCertificatesAsync(CancellationToken.None); + + Assert.Equal(2, result.Count); + HashSet thumbprints = result.Cast().Select(c => c.Thumbprint).ToHashSet(); + Assert.Contains(root.Thumbprint, thumbprints); + Assert.Contains(intermediate.Thumbprint, thumbprints); + Assert.DoesNotContain(leaf.Thumbprint, thumbprints); + } + } + + private static Mock CreateClientReturningChain(params X509Certificate2[] certificates) + { + X509Certificate2Collection collection = new(certificates); + byte[] pkcs7 = collection.Export(X509ContentType.Pkcs7)!; + + Mock> response = new(); + response.SetupGet(_ => _.Value).Returns(new MemoryStream(pkcs7)); + + Mock client = new(); + client + .Setup(_ => _.GetSignCertificateChainAsync(AccountName, CertificateProfileName, It.IsAny())) + .ReturnsAsync(response.Object); + + return client; + } + + /// + /// Builds a certificate chain with a root, intermediate, and leaf certificate. + /// + private static (X509Certificate2 root, X509Certificate2 intermediate, X509Certificate2 leaf) BuildCertificateChain() + { + DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddDays(-1); + DateTimeOffset notAfter = DateTimeOffset.UtcNow.AddDays(1); + + using RSA rootKey = RSA.Create(2048); + CertificateRequest rootRequest = new("CN=Test Root", rootKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true)); + rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, critical: true)); + X509Certificate2 root = rootRequest.CreateSelfSigned(notBefore, notAfter); + + using RSA intermediateKey = RSA.Create(2048); + CertificateRequest intermediateRequest = new("CN=Test Intermediate", intermediateKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + intermediateRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true)); + intermediateRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, critical: true)); + X509Certificate2 intermediateNoKey = intermediateRequest.Create(root, notBefore, notAfter, [1]); + X509Certificate2 intermediate = intermediateNoKey.CopyWithPrivateKey(intermediateKey); + intermediateNoKey.Dispose(); + + using RSA leafKey = RSA.Create(2048); + CertificateRequest leafRequest = new("CN=Test Leaf", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + X509Certificate2 leafNoKey = leafRequest.Create(intermediate, notBefore, notAfter, [2]); + X509Certificate2 leaf = leafNoKey.CopyWithPrivateKey(leafKey); + leafNoKey.Dispose(); + + return (root, intermediate, leaf); + } + } +} diff --git a/test/Sign.SignatureProviders.ArtifactSigning.Test/RSATrustedSigningTests.cs b/test/Sign.SignatureProviders.ArtifactSigning.Test/RSAArtifactSigningTests.cs similarity index 99% rename from test/Sign.SignatureProviders.ArtifactSigning.Test/RSATrustedSigningTests.cs rename to test/Sign.SignatureProviders.ArtifactSigning.Test/RSAArtifactSigningTests.cs index 54ea1208..c24920fd 100644 --- a/test/Sign.SignatureProviders.ArtifactSigning.Test/RSATrustedSigningTests.cs +++ b/test/Sign.SignatureProviders.ArtifactSigning.Test/RSAArtifactSigningTests.cs @@ -2,17 +2,17 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE.txt file in the project root for more information. -using System.Security.Cryptography; using Azure; using Azure.CodeSigning; using Azure.CodeSigning.Models; using Moq; using Moq.Protected; using Sign.SignatureProviders.ArtifactSigning; +using System.Security.Cryptography; namespace Sign.SignatureProviders.KeyVault.Test { - public class RSATrustedSigningTests + public class RSAArtifactSigningTests { private static readonly string AccountName = "testAccount"; private static readonly string CertificateProfileName = "testProfile"; diff --git a/test/Sign.SignatureProviders.ArtifactSigning.Test/TrustedSigningServiceTests.cs b/test/Sign.SignatureProviders.ArtifactSigning.Test/TrustedSigningServiceTests.cs deleted file mode 100644 index 8e221ad5..00000000 --- a/test/Sign.SignatureProviders.ArtifactSigning.Test/TrustedSigningServiceTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE.txt file in the project root for more information. - -using Azure.CodeSigning; -using Microsoft.Extensions.Logging; -using Moq; -using Sign.SignatureProviders.ArtifactSigning; - -namespace Sign.SignatureProviders.TrustedSigning.Test -{ - public class TrustedSigningServiceTests - { - private static readonly CertificateProfileClient CertificateProfileClient = Mock.Of(); - private const string AccountName = "a"; - private const string CertificateProfileName = "b"; - private static readonly ILogger Logger = Mock.Of>(); - - [Fact] - public void Constructor_WhenCertificateProfileClientIsNull_Throws() - { - ArgumentNullException exception = Assert.Throws( - () => new ArtifactSigningService(certificateProfileClient: null!, AccountName, CertificateProfileName, Logger)); - - Assert.Equal("certificateProfileClient", exception.ParamName); - } - - [Fact] - public void Constructor_WhenAccountNameIsNull_Throws() - { - ArgumentNullException exception = Assert.Throws( - () => new ArtifactSigningService(CertificateProfileClient, accountName: null!, CertificateProfileName, Logger)); - - Assert.Equal("accountName", exception.ParamName); - } - - [Fact] - public void Constructor_WhenAccountNameIsEmpty_Throws() - { - ArgumentException exception = Assert.Throws( - () => new ArtifactSigningService(CertificateProfileClient, accountName: string.Empty, CertificateProfileName, Logger)); - - Assert.Equal("accountName", exception.ParamName); - } - - [Fact] - public void Constructor_WhenCertificateProfileNameIsNull_Throws() - { - ArgumentNullException exception = Assert.Throws( - () => new ArtifactSigningService(CertificateProfileClient, AccountName, certificateProfileName: null!, Logger)); - - Assert.Equal("certificateProfileName", exception.ParamName); - } - - [Fact] - public void Constructor_WhenCertificateProfileNameIsEmpty_Throws() - { - ArgumentException exception = Assert.Throws( - () => new ArtifactSigningService(CertificateProfileClient, AccountName, certificateProfileName: string.Empty, Logger)); - - Assert.Equal("certificateProfileName", exception.ParamName); - } - - [Fact] - public void Constructor_WhenLoggerIsNull_Throws() - { - ArgumentNullException exception = Assert.Throws( - () => new ArtifactSigningService(CertificateProfileClient, AccountName, CertificateProfileName, logger: null!)); - - Assert.Equal("logger", exception.ParamName); - } - } -}