diff --git a/Directory.Packages.props b/Directory.Packages.props
index a4a641b..d59b7de 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,22 +1,24 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
+
-
-
-
+
+
+
+
diff --git a/README.md b/README.md
index 41a392a..53c106a 100644
--- a/README.md
+++ b/README.md
@@ -199,6 +199,30 @@ This tool uses the same mechanisms for signing as the Windows SDK `signtool`. It
However, the formats that `azuresigntool` and `signtool` support vary by operating system and which Subject Interface Packages are
present on the system.
+### NuGet Packages
+
+NuGet packages (`.nupkg`) and symbol packages (`.snupkg`) are signed with a NuGet author signature, the same kind of signature
+produced by `dotnet nuget sign`. Files are detected by their extension, so NuGet packages can be signed alongside other files in a
+single invocation:
+
+ AzureSignTool.exe sign -kvu https://my-vault.vault.azure.net \
+ -kvm -kvc my-key-name \
+ -fd sha256 \
+ -tr http://timestamp.digicert.com -td sha256 \
+ C:\packages\*.nupkg
+
+When signing NuGet packages:
+
+* `--file-digest` and `--timestamp-digest` must be `sha256`, `sha384`, or `sha512`. SHA1 is not permitted.
+* Only RFC 3161 timestamps (`--timestamp-rfc3161`) are supported. `--timestamp-authenticode` cannot be used.
+* `--append-signature` is not supported. A package can only have one author signature, and any existing signature is replaced
+ unless `--skip-signed` is specified.
+* `--description`, `--description-url`, `--page-hashing`, and `--no-page-hashing` have no effect.
+* ZIP64 packages cannot be signed.
+
+The certificate must meet the [NuGet signed package requirements](https://learn.microsoft.com/nuget/reference/signed-packages-reference),
+such as having the code signing extended key usage, to be accepted by nuget.org. Signed packages can be verified with `dotnet nuget verify`.
+
## Exit Codes
The exit code is an HRESULT. Successfully signing produces a result of `S_OK` ("0"). If all files fail to sign, the exit code is
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..3140116
--- /dev/null
+++ b/global.json
@@ -0,0 +1,5 @@
+{
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
diff --git a/src/AzureSign.Core/AlgorithmTranslator.cs b/src/AzureSign.Core/AlgorithmTranslator.cs
index 3e240c6..117c1ad 100644
--- a/src/AzureSign.Core/AlgorithmTranslator.cs
+++ b/src/AzureSign.Core/AlgorithmTranslator.cs
@@ -1,5 +1,6 @@
using System;
using System.Security.Cryptography;
+using System.Text;
namespace AzureSign.Core
{
@@ -28,5 +29,11 @@ public static ReadOnlySpan HashAlgorithmToOidAsciiTerminated(HashAlgorithm
_ => throw new NotSupportedException("The algorithm specified is not supported."),
};
}
+
+ public static string HashAlgorithmToOid(HashAlgorithmName hashAlgorithmName)
+ {
+ ReadOnlySpan oid = HashAlgorithmToOidAsciiTerminated(hashAlgorithmName);
+ return Encoding.ASCII.GetString(oid.Slice(0, oid.Length - 1).ToArray());
+ }
}
}
diff --git a/src/AzureSign.Core/AuthenticodeKeyVaultSigner.cs b/src/AzureSign.Core/AuthenticodeKeyVaultSigner.cs
index 7b2626e..2a768a3 100644
--- a/src/AzureSign.Core/AuthenticodeKeyVaultSigner.cs
+++ b/src/AzureSign.Core/AuthenticodeKeyVaultSigner.cs
@@ -43,21 +43,8 @@ public AuthenticodeKeyVaultSigner(AsymmetricAlgorithm signingAlgorithm, X509Cert
_timeStampConfiguration = timeStampConfiguration ?? throw new ArgumentNullException(nameof(timeStampConfiguration));
_signingAlgorithm = signingAlgorithm ?? throw new ArgumentNullException(nameof(signingAlgorithm));
_certificateStore = MemoryCertificateStore.Create();
- _chain = new X509Chain();
+ _chain = CertificateChainBuilder.Build(signingCertificate, additionalCertificates);
- if (additionalCertificates is not null)
- {
- _chain.ChainPolicy.ExtraStore.AddRange(additionalCertificates);
- }
-
- //We don't care about the trustworthiness of the cert. We just want a chain to sign with.
- _chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags;
-
-
- if (!_chain.Build(signingCertificate))
- {
- throw new InvalidOperationException("Failed to build chain for certificate.");
- }
for (var i = 0; i < _chain.ChainElements.Count; i++)
{
_certificateStore.Add(_chain.ChainElements[i].Certificate);
diff --git a/src/AzureSign.Core/AzureSign.Core.csproj b/src/AzureSign.Core/AzureSign.Core.csproj
index a686c2a..06e6d9e 100644
--- a/src/AzureSign.Core/AzureSign.Core.csproj
+++ b/src/AzureSign.Core/AzureSign.Core.csproj
@@ -21,4 +21,8 @@
+
+
+
+
diff --git a/src/AzureSign.Core/CertificateChainBuilder.cs b/src/AzureSign.Core/CertificateChainBuilder.cs
new file mode 100644
index 0000000..cbae8b0
--- /dev/null
+++ b/src/AzureSign.Core/CertificateChainBuilder.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Security.Cryptography.X509Certificates;
+
+namespace AzureSign.Core
+{
+ internal static class CertificateChainBuilder
+ {
+ public static X509Chain Build(X509Certificate2 certificate, X509Certificate2Collection? additionalCertificates)
+ {
+ X509Chain chain = new X509Chain();
+
+ try
+ {
+ if (additionalCertificates is not null)
+ {
+ chain.ChainPolicy.ExtraStore.AddRange(additionalCertificates);
+ }
+
+ //We don't care about the trustworthiness of the cert. We just want a chain to sign with.
+ chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags;
+
+ if (!chain.Build(certificate))
+ {
+ throw new InvalidOperationException("Failed to build chain for certificate.");
+ }
+
+ return chain;
+ }
+ catch
+ {
+ chain.Dispose();
+ throw;
+ }
+ }
+ }
+}
diff --git a/src/AzureSign.Core/NuGetKeyVaultSigner.cs b/src/AzureSign.Core/NuGetKeyVaultSigner.cs
new file mode 100644
index 0000000..8c591dd
--- /dev/null
+++ b/src/AzureSign.Core/NuGetKeyVaultSigner.cs
@@ -0,0 +1,371 @@
+#if NET
+using Microsoft.Extensions.Logging;
+using System;
+using System.Formats.Asn1;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Security.Cryptography.Pkcs;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AzureSign.Core
+{
+ ///
+ /// Signs a NuGet package (.nupkg or .snupkg) with a NuGet author signature.
+ ///
+ public class NuGetKeyVaultSigner : IDisposable
+ {
+ private const int S_OK = 0;
+ private const int E_FAIL = unchecked((int)0x80004005);
+
+ private const string SubjectKeyIdentifierOid = "2.5.29.14";
+ private const string CommitmentTypeIndicationOid = "1.2.840.113549.1.9.16.2.16";
+ private const string CommitmentTypeIdentifierProofOfOriginOid = "1.2.840.113549.1.9.16.6.1";
+ private const string SigningCertificateV2Oid = "1.2.840.113549.1.9.16.2.47";
+ private const string SignatureTimeStampTokenOid = "1.2.840.113549.1.9.16.2.14";
+
+ private const int TimestampMaxAttempts = 3;
+
+ private static readonly HttpClient s_httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
+
+ private readonly AsymmetricAlgorithm _signingAlgorithm;
+ private readonly X509Certificate2 _signingCertificate;
+ private readonly HashAlgorithmName _fileDigestAlgorithm;
+ private readonly string _fileDigestOid;
+ private readonly TimeStampConfiguration _timeStampConfiguration;
+ private readonly X509Chain _chain;
+
+ ///
+ /// Creates a new instance of .
+ ///
+ ///
+ /// An instance of an RSA algorithm that will be used to sign. It must support signing with a private key.
+ ///
+ /// The X509 public certificate for the .
+ /// The digest algorithm used to hash the package and the signature. Must be SHA256, SHA384, or SHA512.
+ /// The timestamp configuration for timestamping the package. Only RFC 3161 timestamps
+ /// are supported. To omit timestamping, use .
+ /// Any additional certificates to assist in building a certificate chain.
+ public NuGetKeyVaultSigner(AsymmetricAlgorithm signingAlgorithm, X509Certificate2 signingCertificate,
+ HashAlgorithmName fileDigestAlgorithm, TimeStampConfiguration timeStampConfiguration,
+ X509Certificate2Collection? additionalCertificates = null)
+ {
+ _signingAlgorithm = signingAlgorithm ?? throw new ArgumentNullException(nameof(signingAlgorithm));
+ _signingCertificate = signingCertificate ?? throw new ArgumentNullException(nameof(signingCertificate));
+ _timeStampConfiguration = timeStampConfiguration ?? throw new ArgumentNullException(nameof(timeStampConfiguration));
+
+ if (signingAlgorithm is not RSA)
+ {
+ throw new ArgumentException("NuGet package signing requires an RSA key.", nameof(signingAlgorithm));
+ }
+
+ if (!IsSupportedDigestAlgorithm(fileDigestAlgorithm))
+ {
+ throw new ArgumentException("NuGet package signing requires a SHA256, SHA384, or SHA512 file digest.", nameof(fileDigestAlgorithm));
+ }
+
+ if (timeStampConfiguration.Type == TimeStampType.Authenticode)
+ {
+ throw new ArgumentException("NuGet package signing only supports RFC 3161 timestamps.", nameof(timeStampConfiguration));
+ }
+
+ if (timeStampConfiguration.Type == TimeStampType.RFC3161 && !IsSupportedDigestAlgorithm(timeStampConfiguration.DigestAlgorithm))
+ {
+ throw new ArgumentException("NuGet package signing requires a SHA256, SHA384, or SHA512 timestamp digest.", nameof(timeStampConfiguration));
+ }
+
+ _fileDigestAlgorithm = fileDigestAlgorithm;
+ _fileDigestOid = AlgorithmTranslator.HashAlgorithmToOid(fileDigestAlgorithm);
+ _chain = CertificateChainBuilder.Build(signingCertificate, additionalCertificates);
+ }
+
+ ///
+ /// Determines if a path refers to a NuGet package, based on its file extension.
+ ///
+ /// The path to the file.
+ /// if the file has a .nupkg or .snupkg extension, otherwise .
+ public static bool IsNuGetPackage(ReadOnlySpan path)
+ {
+ ReadOnlySpan extension = Path.GetExtension(path);
+ return extension.Equals(".nupkg", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".snupkg", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Determines if a NuGet package contains a package signature.
+ ///
+ /// The path to the NuGet package.
+ /// if the package is signed, otherwise .
+ public static bool IsSigned(string path)
+ {
+ try
+ {
+ using FileStream stream = File.OpenRead(path);
+ return NuGetPackageArchive.Read(stream).IsSigned;
+ }
+ catch (Exception e) when (e is InvalidDataException or NotSupportedException or EndOfStreamException or IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ /// Signs a NuGet package with an author signature. Any existing package signature is replaced.
+ /// The path to the package to sign.
+ /// An optional logger to capture signing operations.
+ /// A token to cancel the timestamp request.
+ /// A HRESULT indicating the result of the signing operation. S_OK, or zero, is returned if the signing
+ /// operation completed successfully.
+ public int SignFile(string path, ILogger? logger = null, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Open the package for writing up front so a read-only or locked package fails before any signing work is done.
+ using FileStream package = new(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+ NuGetPackageArchive archive = NuGetPackageArchive.Read(package);
+ Stream unsigned = package;
+
+ try
+ {
+ if (archive.IsSigned)
+ {
+ logger?.LogTrace("Removing existing package signature.");
+ unsigned = CreateTemporaryFile();
+ archive.WriteWithoutSignature(package, unsigned);
+ unsigned.Position = 0;
+ archive = NuGetPackageArchive.Read(unsigned);
+ }
+
+ logger?.LogTrace("Hashing package.");
+ unsigned.Position = 0;
+ byte[] packageHash = HashStream(unsigned, _fileDigestAlgorithm);
+
+ logger?.LogTrace("Creating package signature.");
+ byte[] signature = CreateSignature(packageHash, logger, cancellationToken);
+
+ using FileStream signed = CreateTemporaryFile();
+ archive.WriteWithSignature(unsigned, signed, signature);
+
+ // Write the signed package over the original file instead of replacing it, so links,
+ // permissions, and other file metadata are kept.
+ signed.Position = 0;
+ package.Position = 0;
+ signed.CopyTo(package);
+ package.SetLength(signed.Length);
+ }
+ finally
+ {
+ if (!ReferenceEquals(unsigned, package))
+ {
+ unsigned.Dispose();
+ }
+ }
+
+ return S_OK;
+ }
+ catch (Exception e)
+ {
+ logger?.LogError(e, "Failed to sign NuGet package: {message}", e.Message);
+ return e.HResult != 0 ? e.HResult : E_FAIL;
+ }
+ }
+
+ ///
+ /// Frees all resources used by the .
+ ///
+ public void Dispose()
+ {
+ _chain.Dispose();
+ }
+
+ private byte[] CreateSignature(byte[] packageHash, ILogger? logger, CancellationToken cancellationToken)
+ {
+ // The signature content is defined by the NuGet package signing specification, version 1.
+ string content = $"Version:1\n\n{_fileDigestOid}-Hash:{Convert.ToBase64String(packageHash)}\n\n";
+ byte[] contentBytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
+
+ // Subject Key Identifier is preferred, but should only be used if the certificate has the extension.
+ SubjectIdentifierType identifierType = _signingCertificate.Extensions[SubjectKeyIdentifierOid] is null
+ ? SubjectIdentifierType.IssuerAndSerialNumber
+ : SubjectIdentifierType.SubjectKeyIdentifier;
+
+ CmsSigner signer = new(identifierType, _signingCertificate, _signingAlgorithm)
+ {
+ DigestAlgorithm = new Oid(_fileDigestOid),
+ IncludeOption = X509IncludeOption.None,
+ };
+
+ foreach (X509ChainElement element in _chain.ChainElements)
+ {
+ signer.Certificates.Add(element.Certificate);
+ }
+
+ signer.SignedAttributes.Add(new Pkcs9SigningTime());
+ signer.SignedAttributes.Add(new AsnEncodedData(CommitmentTypeIndicationOid, EncodeCommitmentTypeIndication()));
+ signer.SignedAttributes.Add(new AsnEncodedData(SigningCertificateV2Oid, EncodeSigningCertificateV2(_signingCertificate, _fileDigestAlgorithm, _fileDigestOid)));
+
+ SignedCms cms = new(new ContentInfo(contentBytes), detached: false);
+ cms.ComputeSignature(signer, silent: true);
+
+ if (_timeStampConfiguration.Type == TimeStampType.RFC3161)
+ {
+ logger?.LogTrace("Timestamping package signature.");
+ Timestamp(cms.SignerInfos[0], _timeStampConfiguration.Url!, _timeStampConfiguration.DigestAlgorithm, logger, cancellationToken);
+ }
+
+ return cms.Encode();
+ }
+
+ private static void Timestamp(SignerInfo signerInfo, string timestampUrl, HashAlgorithmName digestAlgorithm, ILogger? logger, CancellationToken cancellationToken)
+ {
+ byte[] nonce = new byte[8];
+ RandomNumberGenerator.Fill(nonce);
+
+ Rfc3161TimestampRequest request = Rfc3161TimestampRequest.CreateFromSignerInfo(
+ signerInfo,
+ digestAlgorithm,
+ requestedPolicyId: null,
+ nonce: nonce,
+ requestSignerCertificates: true);
+
+ byte[] responseBytes = SendTimestampRequest(request.Encode(), timestampUrl, logger, cancellationToken);
+
+ // ProcessResponse validates the status, the message imprint, and the nonce.
+ Rfc3161TimestampToken token = request.ProcessResponse(responseBytes, out _);
+ signerInfo.AddUnsignedAttribute(new AsnEncodedData(SignatureTimeStampTokenOid, token.AsSignedCms().Encode()));
+ }
+
+ private static byte[] SendTimestampRequest(byte[] request, string timestampUrl, ILogger? logger, CancellationToken cancellationToken)
+ {
+ for (int attempt = 1; ; attempt++)
+ {
+ try
+ {
+ using HttpRequestMessage message = new(HttpMethod.Post, timestampUrl)
+ {
+ Content = new ByteArrayContent(request),
+ };
+ message.Content.Headers.ContentType = new MediaTypeHeaderValue("application/timestamp-query");
+
+ using HttpResponseMessage response = s_httpClient.Send(message, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ using Stream responseStream = response.Content.ReadAsStream(cancellationToken);
+ using MemoryStream buffer = new();
+ responseStream.CopyTo(buffer);
+ return buffer.ToArray();
+ }
+ catch (Exception e) when (attempt < TimestampMaxAttempts && IsTransientTimestampFailure(e, cancellationToken))
+ {
+ TimeSpan delay = TimeSpan.FromSeconds(2 * attempt);
+ logger?.LogWarning("Timestamp request failed: {message} Retrying in {delay} seconds.", e.Message, delay.TotalSeconds);
+
+ if (cancellationToken.WaitHandle.WaitOne(delay))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ }
+ }
+ }
+
+ private static bool IsTransientTimestampFailure(Exception exception, CancellationToken cancellationToken)
+ {
+ return exception switch
+ {
+ // HttpClient reports its own timeout as a TaskCanceledException.
+ TaskCanceledException => !cancellationToken.IsCancellationRequested,
+ HttpRequestException { StatusCode: null } => true,
+ HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests } => true,
+ HttpRequestException { StatusCode: >= HttpStatusCode.InternalServerError } => true,
+ _ => false,
+ };
+ }
+
+ private static byte[] EncodeCommitmentTypeIndication()
+ {
+ // CommitmentTypeIndication ::= SEQUENCE { commitmentTypeId CommitmentTypeIdentifier, ... }
+ AsnWriter writer = new(AsnEncodingRules.DER);
+
+ using (writer.PushSequence())
+ {
+ writer.WriteObjectIdentifier(CommitmentTypeIdentifierProofOfOriginOid);
+ }
+
+ return writer.Encode();
+ }
+
+ private static byte[] EncodeSigningCertificateV2(X509Certificate2 certificate, HashAlgorithmName hashAlgorithm, string hashOid)
+ {
+ // SigningCertificateV2 ::= SEQUENCE { certs SEQUENCE OF ESSCertIDv2 }
+ // ESSCertIDv2 ::= SEQUENCE { hashAlgorithm AlgorithmIdentifier, certHash OCTET STRING, issuerSerial IssuerSerial }
+ // IssuerSerial ::= SEQUENCE { issuer GeneralNames, serialNumber CertificateSerialNumber }
+ byte[] certificateHash = HashData(hashAlgorithm, certificate.RawData);
+ AsnWriter writer = new(AsnEncodingRules.DER);
+
+ using (writer.PushSequence())
+ using (writer.PushSequence())
+ using (writer.PushSequence())
+ {
+ using (writer.PushSequence())
+ {
+ writer.WriteObjectIdentifier(hashOid);
+ }
+
+ writer.WriteOctetString(certificateHash);
+
+ using (writer.PushSequence())
+ {
+ using (writer.PushSequence())
+ using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 4, isConstructed: true)))
+ {
+ // directoryName [4] Name. Name is a CHOICE, so the tag is explicit.
+ writer.WriteEncodedValue(certificate.IssuerName.RawData);
+ }
+
+ writer.WriteInteger(certificate.SerialNumberBytes.Span);
+ }
+ }
+
+ return writer.Encode();
+ }
+
+ private static byte[] HashStream(Stream stream, HashAlgorithmName hashAlgorithm)
+ {
+ using IncrementalHash hash = IncrementalHash.CreateHash(hashAlgorithm);
+ byte[] buffer = new byte[81920];
+ int read;
+
+ while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ hash.AppendData(buffer, 0, read);
+ }
+
+ return hash.GetHashAndReset();
+ }
+
+ private static byte[] HashData(HashAlgorithmName hashAlgorithm, byte[] data)
+ {
+ using IncrementalHash hash = IncrementalHash.CreateHash(hashAlgorithm);
+ hash.AppendData(data);
+ return hash.GetHashAndReset();
+ }
+
+ private static bool IsSupportedDigestAlgorithm(HashAlgorithmName hashAlgorithm)
+ {
+ return hashAlgorithm == HashAlgorithmName.SHA256 ||
+ hashAlgorithm == HashAlgorithmName.SHA384 ||
+ hashAlgorithm == HashAlgorithmName.SHA512;
+ }
+
+ private static FileStream CreateTemporaryFile()
+ {
+ string path = Path.Combine(Path.GetTempPath(), $"AzureSignTool.{Guid.NewGuid():N}.nupkg.tmp");
+ return new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.DeleteOnClose);
+ }
+ }
+}
+#endif
diff --git a/src/AzureSign.Core/NuGetPackageArchive.cs b/src/AzureSign.Core/NuGetPackageArchive.cs
new file mode 100644
index 0000000..9de31a0
--- /dev/null
+++ b/src/AzureSign.Core/NuGetPackageArchive.cs
@@ -0,0 +1,359 @@
+#if NET
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+namespace AzureSign.Core
+{
+ ///
+ /// Reads and rewrites the ZIP structures of a NuGet package in the way the NuGet package signing
+ /// specification requires. The package signature file is inserted as a stored (uncompressed) entry
+ /// after the last local file entry, and its central directory header after the last central directory header.
+ ///
+ internal sealed class NuGetPackageArchive
+ {
+ internal const string SignatureFileName = ".signature.p7s";
+
+ private const uint LocalFileHeaderSignature = 0x04034b50;
+ private const uint CentralDirectoryHeaderSignature = 0x02014b50;
+ private const uint EndOfCentralDirectorySignature = 0x06054b50;
+ private const uint Zip64EndOfCentralDirectoryLocatorSignature = 0x07064b50;
+ private const int LocalFileHeaderFixedSize = 30;
+ private const int CentralDirectoryHeaderFixedSize = 46;
+ private const int EndOfCentralDirectoryFixedSize = 22;
+ private const int Zip64EndOfCentralDirectoryLocatorSize = 20;
+
+ private static readonly byte[] s_signatureFileNameBytes = Encoding.ASCII.GetBytes(SignatureFileName);
+
+ private NuGetPackageArchive(long endOfCentralDirectoryPosition, long centralDirectoryOffset, List records, int signatureRecordIndex)
+ {
+ EndOfCentralDirectoryPosition = endOfCentralDirectoryPosition;
+ CentralDirectoryOffset = centralDirectoryOffset;
+ Records = records;
+ SignatureRecordIndex = signatureRecordIndex;
+ }
+
+ public long EndOfCentralDirectoryPosition { get; }
+ public long CentralDirectoryOffset { get; }
+ public List Records { get; }
+ public int SignatureRecordIndex { get; }
+ public bool IsSigned => SignatureRecordIndex >= 0;
+
+ public static NuGetPackageArchive Read(Stream stream)
+ {
+ long eocdPosition = FindEndOfCentralDirectory(stream);
+ byte[] eocd = ReadExactly(stream, eocdPosition, EndOfCentralDirectoryFixedSize);
+
+ ushort diskNumber = BinaryPrimitives.ReadUInt16LittleEndian(eocd.AsSpan(4));
+ ushort centralDirectoryDisk = BinaryPrimitives.ReadUInt16LittleEndian(eocd.AsSpan(6));
+ ushort entriesOnDisk = BinaryPrimitives.ReadUInt16LittleEndian(eocd.AsSpan(8));
+ ushort totalEntries = BinaryPrimitives.ReadUInt16LittleEndian(eocd.AsSpan(10));
+ uint centralDirectorySize = BinaryPrimitives.ReadUInt32LittleEndian(eocd.AsSpan(12));
+ uint centralDirectoryOffset = BinaryPrimitives.ReadUInt32LittleEndian(eocd.AsSpan(16));
+
+ if (totalEntries == ushort.MaxValue || entriesOnDisk == ushort.MaxValue ||
+ centralDirectorySize == uint.MaxValue || centralDirectoryOffset == uint.MaxValue ||
+ HasZip64Locator(stream, eocdPosition))
+ {
+ throw new NotSupportedException("ZIP64 packages cannot be signed.");
+ }
+
+ if (diskNumber != 0 || centralDirectoryDisk != 0 || entriesOnDisk != totalEntries)
+ {
+ throw new InvalidDataException("Multi-disk archives are not supported.");
+ }
+
+ if ((long)centralDirectoryOffset + centralDirectorySize != eocdPosition)
+ {
+ throw new InvalidDataException("The central directory of the package is not immediately followed by the end of central directory record.");
+ }
+
+ byte[] centralDirectory = ReadExactly(stream, centralDirectoryOffset, (int)centralDirectorySize);
+ List records = new(totalEntries);
+ int signatureRecordIndex = -1;
+ int position = 0;
+
+ for (int i = 0; i < totalEntries; i++)
+ {
+ ReadOnlySpan header = centralDirectory.AsSpan(position);
+
+ if (header.Length < CentralDirectoryHeaderFixedSize ||
+ BinaryPrimitives.ReadUInt32LittleEndian(header) != CentralDirectoryHeaderSignature)
+ {
+ throw new InvalidDataException("The package has an invalid central directory header.");
+ }
+
+ ushort fileNameLength = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(28));
+ ushort extraLength = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(30));
+ ushort commentLength = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(32));
+ uint localHeaderOffset = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(42));
+ int headerSize = CentralDirectoryHeaderFixedSize + fileNameLength + extraLength + commentLength;
+
+ if (header.Length < headerSize)
+ {
+ throw new InvalidDataException("The package has an invalid central directory header.");
+ }
+
+ // The signature file name is ASCII, so it is the same bytes whether or not the UTF-8 flag (bit 11) is set.
+ // Recognize it either way so an existing signature is never missed and a second one is never added.
+ bool isSignature = header.Slice(CentralDirectoryHeaderFixedSize, fileNameLength).SequenceEqual(s_signatureFileNameBytes);
+
+ if (isSignature)
+ {
+ if (signatureRecordIndex >= 0)
+ {
+ throw new InvalidDataException("The package contains multiple package signature files.");
+ }
+
+ signatureRecordIndex = i;
+ }
+
+ records.Add(new CentralDirectoryRecord(centralDirectory.AsSpan(position, headerSize).ToArray(), localHeaderOffset));
+ position += headerSize;
+ }
+
+ if (records.Count == 0)
+ {
+ throw new InvalidDataException("The package does not contain any entries.");
+ }
+
+ ComputeLocalEntrySizes(records, centralDirectoryOffset);
+
+ return new NuGetPackageArchive(eocdPosition, centralDirectoryOffset, records, signatureRecordIndex);
+ }
+
+ ///
+ /// Writes a copy of the unsigned package in with added as the package signature file.
+ ///
+ public void WriteWithSignature(Stream source, Stream destination, byte[] signature)
+ {
+ if (IsSigned)
+ {
+ throw new InvalidOperationException("The package is already signed.");
+ }
+
+ if (Records.Count >= ushort.MaxValue - 1)
+ {
+ throw new NotSupportedException("Signing the package would require ZIP64.");
+ }
+
+ uint crc = Crc32(signature);
+ uint dosDateTime = ToDosDateTime(DateTime.Now);
+
+ // All local file entries.
+ CopyRange(source, destination, 0, CentralDirectoryOffset);
+
+ long signatureLocalHeaderOffset = destination.Position;
+ Span localHeader = stackalloc byte[LocalFileHeaderFixedSize];
+ BinaryPrimitives.WriteUInt32LittleEndian(localHeader, LocalFileHeaderSignature);
+ BinaryPrimitives.WriteUInt16LittleEndian(localHeader.Slice(4), 20); // version needed to extract
+ BinaryPrimitives.WriteUInt16LittleEndian(localHeader.Slice(6), 0); // general purpose bit flag
+ BinaryPrimitives.WriteUInt16LittleEndian(localHeader.Slice(8), 0); // stored
+ BinaryPrimitives.WriteUInt32LittleEndian(localHeader.Slice(10), dosDateTime);
+ BinaryPrimitives.WriteUInt32LittleEndian(localHeader.Slice(14), crc);
+ BinaryPrimitives.WriteUInt32LittleEndian(localHeader.Slice(18), (uint)signature.Length);
+ BinaryPrimitives.WriteUInt32LittleEndian(localHeader.Slice(22), (uint)signature.Length);
+ BinaryPrimitives.WriteUInt16LittleEndian(localHeader.Slice(26), (ushort)s_signatureFileNameBytes.Length);
+ BinaryPrimitives.WriteUInt16LittleEndian(localHeader.Slice(28), 0); // extra field length
+ destination.Write(localHeader);
+ destination.Write(s_signatureFileNameBytes);
+ destination.Write(signature);
+ long signatureEntrySize = destination.Position - signatureLocalHeaderOffset;
+
+ // All existing central directory headers.
+ CopyRange(source, destination, CentralDirectoryOffset, EndOfCentralDirectoryPosition);
+
+ Span centralHeader = stackalloc byte[CentralDirectoryHeaderFixedSize];
+ BinaryPrimitives.WriteUInt32LittleEndian(centralHeader, CentralDirectoryHeaderSignature);
+ BinaryPrimitives.WriteUInt16LittleEndian(centralHeader.Slice(4), 20); // version made by
+ BinaryPrimitives.WriteUInt16LittleEndian(centralHeader.Slice(6), 20); // version needed to extract
+ BinaryPrimitives.WriteUInt16LittleEndian(centralHeader.Slice(8), 0); // general purpose bit flag
+ BinaryPrimitives.WriteUInt16LittleEndian(centralHeader.Slice(10), 0); // stored
+ BinaryPrimitives.WriteUInt32LittleEndian(centralHeader.Slice(12), dosDateTime);
+ BinaryPrimitives.WriteUInt32LittleEndian(centralHeader.Slice(16), crc);
+ BinaryPrimitives.WriteUInt32LittleEndian(centralHeader.Slice(20), (uint)signature.Length);
+ BinaryPrimitives.WriteUInt32LittleEndian(centralHeader.Slice(24), (uint)signature.Length);
+ BinaryPrimitives.WriteUInt16LittleEndian(centralHeader.Slice(28), (ushort)s_signatureFileNameBytes.Length);
+ // Extra field length, comment length, disk number, internal and external attributes are all zero.
+ BinaryPrimitives.WriteUInt32LittleEndian(centralHeader.Slice(42), checked((uint)signatureLocalHeaderOffset));
+ destination.Write(centralHeader);
+ destination.Write(s_signatureFileNameBytes);
+ long signatureCentralHeaderSize = CentralDirectoryHeaderFixedSize + s_signatureFileNameBytes.Length;
+
+ WriteUpdatedEndOfCentralDirectory(source, destination, 1, signatureCentralHeaderSize, signatureEntrySize);
+ }
+
+ ///
+ /// Writes a copy of the package in with the package signature file removed.
+ ///
+ public void WriteWithoutSignature(Stream source, Stream destination)
+ {
+ if (!IsSigned)
+ {
+ throw new InvalidOperationException("The package is not signed.");
+ }
+
+ CentralDirectoryRecord signature = Records[SignatureRecordIndex];
+ long signatureEnd = signature.LocalHeaderOffset + signature.LocalEntrySize;
+
+ CopyRange(source, destination, 0, signature.LocalHeaderOffset);
+ CopyRange(source, destination, signatureEnd, CentralDirectoryOffset);
+
+ for (int i = 0; i < Records.Count; i++)
+ {
+ if (i == SignatureRecordIndex)
+ {
+ continue;
+ }
+
+ CentralDirectoryRecord record = Records[i];
+ byte[] header = (byte[])record.Header.Clone();
+
+ if (record.LocalHeaderOffset > signature.LocalHeaderOffset)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(42), (uint)(record.LocalHeaderOffset - signature.LocalEntrySize));
+ }
+
+ destination.Write(header);
+ }
+
+ WriteUpdatedEndOfCentralDirectory(source, destination, -1, -signature.Header.Length, -signature.LocalEntrySize);
+ }
+
+ private static void WriteUpdatedEndOfCentralDirectory(Stream source, Stream destination, int entryCountChange, long centralDirectorySizeChange, long centralDirectoryOffsetChange)
+ {
+ source.Position = FindEndOfCentralDirectory(source);
+ byte[] eocd = ReadExactly(source, source.Position, EndOfCentralDirectoryFixedSize);
+ Span span = eocd;
+
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(8), (ushort)(BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(8)) + entryCountChange));
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(10), (ushort)(BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(10)) + entryCountChange));
+ BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12), checked((uint)(BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(12)) + centralDirectorySizeChange)));
+ BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16), checked((uint)(BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(16)) + centralDirectoryOffsetChange)));
+ destination.Write(eocd);
+
+ // The archive comment, if any.
+ CopyRange(source, destination, source.Position, source.Length);
+ }
+
+ private static void ComputeLocalEntrySizes(List records, long centralDirectoryOffset)
+ {
+ List ordered = new(records);
+ ordered.Sort((x, y) => x.LocalHeaderOffset.CompareTo(y.LocalHeaderOffset));
+
+ for (int i = 0; i < ordered.Count; i++)
+ {
+ long end = i + 1 < ordered.Count ? ordered[i + 1].LocalHeaderOffset : centralDirectoryOffset;
+ ordered[i].LocalEntrySize = end - ordered[i].LocalHeaderOffset;
+ }
+ }
+
+ private static bool HasZip64Locator(Stream stream, long eocdPosition)
+ {
+ if (eocdPosition < Zip64EndOfCentralDirectoryLocatorSize)
+ {
+ return false;
+ }
+
+ byte[] locator = ReadExactly(stream, eocdPosition - Zip64EndOfCentralDirectoryLocatorSize, 4);
+ return BinaryPrimitives.ReadUInt32LittleEndian(locator) == Zip64EndOfCentralDirectoryLocatorSignature;
+ }
+
+ private static long FindEndOfCentralDirectory(Stream stream)
+ {
+ if (stream.Length < EndOfCentralDirectoryFixedSize)
+ {
+ throw new InvalidDataException("The file is not a valid ZIP archive.");
+ }
+
+ // The end of central directory record is followed by a variable length comment of at most 65535 bytes.
+ int searchLength = (int)Math.Min(stream.Length, EndOfCentralDirectoryFixedSize + ushort.MaxValue);
+ long searchStart = stream.Length - searchLength;
+ byte[] buffer = ReadExactly(stream, searchStart, searchLength);
+
+ for (int i = searchLength - EndOfCentralDirectoryFixedSize; i >= 0; i--)
+ {
+ if (BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(i)) == EndOfCentralDirectorySignature &&
+ BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(i + 20)) == searchLength - i - EndOfCentralDirectoryFixedSize)
+ {
+ return searchStart + i;
+ }
+ }
+
+ throw new InvalidDataException("The file is not a valid ZIP archive.");
+ }
+
+ private static byte[] ReadExactly(Stream stream, long position, int count)
+ {
+ byte[] buffer = new byte[count];
+ stream.Position = position;
+ stream.ReadExactly(buffer);
+ return buffer;
+ }
+
+ private static void CopyRange(Stream source, Stream destination, long start, long end)
+ {
+ source.Position = start;
+ byte[] buffer = new byte[81920];
+ long remaining = end - start;
+
+ while (remaining > 0)
+ {
+ int read = source.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining));
+
+ if (read == 0)
+ {
+ throw new EndOfStreamException();
+ }
+
+ destination.Write(buffer, 0, read);
+ remaining -= read;
+ }
+ }
+
+ private static uint Crc32(ReadOnlySpan data)
+ {
+ uint crc = 0xFFFFFFFF;
+
+ foreach (byte b in data)
+ {
+ crc ^= b;
+
+ for (int i = 0; i < 8; i++)
+ {
+ crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
+ }
+ }
+
+ return ~crc;
+ }
+
+ private static uint ToDosDateTime(DateTime dateTime)
+ {
+ int year = Math.Clamp(dateTime.Year, 1980, 2107);
+ int value = (year - 1980) & 0x7F;
+ value = (value << 4) + dateTime.Month;
+ value = (value << 5) + dateTime.Day;
+ value = (value << 5) + dateTime.Hour;
+ value = (value << 6) + dateTime.Minute;
+ value = (value << 5) + (dateTime.Second / 2);
+ return (uint)value;
+ }
+
+ internal sealed class CentralDirectoryRecord
+ {
+ public CentralDirectoryRecord(byte[] header, long localHeaderOffset)
+ {
+ Header = header;
+ LocalHeaderOffset = localHeaderOffset;
+ }
+
+ public byte[] Header { get; }
+ public long LocalHeaderOffset { get; }
+ public long LocalEntrySize { get; set; }
+ }
+ }
+}
+#endif
diff --git a/src/AzureSignTool/Program.cs b/src/AzureSignTool/Program.cs
index 7bc1e12..5aa893c 100644
--- a/src/AzureSignTool/Program.cs
+++ b/src/AzureSignTool/Program.cs
@@ -191,6 +191,7 @@ private ValueTask Run(CommandRunContext context, string[] arguments)
string[] additionalFiles = arguments;
allFiles = GetAllFiles(additionalFiles);
valid = ValidateFiles(context, allFiles);
+ valid &= ValidateNuGetPackageOptions(context, allFiles);
}
if (valid && allFiles is not null)
@@ -312,8 +313,22 @@ private async ValueTask RunSign(HashSet allFiles)
var client = new CryptographyClient(materialized.KeyId, materialized.TokenCredential, clientOptions);
+ bool hasNuGetPackages = false, hasOtherFiles = false;
+ foreach (string file in allFiles)
+ {
+ if (NuGetKeyVaultSigner.IsNuGetPackage(file))
+ {
+ hasNuGetPackages = true;
+ }
+ else
+ {
+ hasOtherFiles = true;
+ }
+ }
+
using (var keyVault = await client.CreateRSAAsync())
- using (var signer = new AuthenticodeKeyVaultSigner(keyVault, materialized.PublicCertificate, ParseHashAlgorithm(FileDigestAlgorithm), timeStampConfiguration, certificates))
+ using (var signer = hasOtherFiles ? new AuthenticodeKeyVaultSigner(keyVault, materialized.PublicCertificate, ParseHashAlgorithm(FileDigestAlgorithm), timeStampConfiguration, certificates) : null)
+ using (var nuGetSigner = hasNuGetPackages ? new NuGetKeyVaultSigner(keyVault, materialized.PublicCertificate, ParseHashAlgorithm(FileDigestAlgorithm), timeStampConfiguration, certificates) : null)
{
Parallel.ForEach(allFiles, options, () => (succeeded: 0, failed: 0), (filePath, pls, state) =>
{
@@ -335,7 +350,9 @@ private async ValueTask RunSign(HashSet allFiles)
return (state.succeeded + 1, state.failed);
}
- var result = signer.SignFile(filePath, SignDescription, SignDescriptionUrl, performPageHashing, logger, appendSignature);
+ var result = NuGetKeyVaultSigner.IsNuGetPackage(filePath)
+ ? nuGetSigner!.SignFile(filePath, logger, cancellationSource.Token)
+ : signer!.SignFile(filePath, SignDescription, SignDescriptionUrl, performPageHashing, logger, appendSignature);
switch (result)
{
case COR_E_BADIMAGEFORMAT:
@@ -389,6 +406,11 @@ private async ValueTask RunSign(HashSet allFiles)
private static bool IsSigned(string filePath)
{
+ if (NuGetKeyVaultSigner.IsNuGetPackage(filePath))
+ {
+ return NuGetKeyVaultSigner.IsSigned(filePath);
+ }
+
try
{
return X509Certificate2.GetCertContentType(filePath) == X509ContentType.Authenticode;
@@ -516,6 +538,53 @@ private static bool ValidateFiles(CommandRunContext context, HashSet all
return valid;
}
+ private bool ValidateNuGetPackageOptions(CommandRunContext context, HashSet allFiles)
+ {
+ bool hasNuGetPackages = false;
+
+ foreach (string file in allFiles)
+ {
+ if (NuGetKeyVaultSigner.IsNuGetPackage(file))
+ {
+ hasNuGetPackages = true;
+ break;
+ }
+ }
+
+ if (!hasNuGetPackages)
+ {
+ return true;
+ }
+
+ bool valid = true;
+
+ if ("SHA1".Equals(FileDigestAlgorithm, StringComparison.OrdinalIgnoreCase))
+ {
+ context.Error.WriteLine("NuGet packages cannot be signed with a SHA1 '--file-digest'.");
+ valid = false;
+ }
+
+ if (Rfc3161TimestampUrl is not null && "SHA1".Equals(TimestampDigestAlgorithm, StringComparison.OrdinalIgnoreCase))
+ {
+ context.Error.WriteLine("NuGet packages cannot be timestamped with a SHA1 '--timestamp-digest'.");
+ valid = false;
+ }
+
+ if (AuthenticodeTimestampUrl is not null)
+ {
+ context.Error.WriteLine("NuGet packages cannot be timestamped with '--timestamp-authenticode'. Use '--timestamp-rfc3161' instead.");
+ valid = false;
+ }
+
+ if (AppendSignature)
+ {
+ context.Error.WriteLine("NuGet packages do not support '--append-signature'.");
+ valid = false;
+ }
+
+ return valid;
+ }
+
private static bool ValidateHashAlgorithm(CommandRunContext context, string? input, string optionName)
{
if (input is null)
diff --git a/test/AzureSign.Core.Tests/AzureSign.Core.Tests.csproj b/test/AzureSign.Core.Tests/AzureSign.Core.Tests.csproj
index e2dbff8..3820c11 100644
--- a/test/AzureSign.Core.Tests/AzureSign.Core.Tests.csproj
+++ b/test/AzureSign.Core.Tests/AzureSign.Core.Tests.csproj
@@ -8,6 +8,7 @@
+
all
diff --git a/test/AzureSign.Core.Tests/NuGetKeyVaultSignerTests.cs b/test/AzureSign.Core.Tests/NuGetKeyVaultSignerTests.cs
new file mode 100644
index 0000000..e97a3c4
--- /dev/null
+++ b/test/AzureSign.Core.Tests/NuGetKeyVaultSignerTests.cs
@@ -0,0 +1,365 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Security.Cryptography.Pkcs;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NuGet.Packaging;
+using NuGet.Packaging.Signing;
+using Xunit;
+
+namespace AzureSign.Core.Tests
+{
+ public class NuGetKeyVaultSignerTests : IDisposable
+ {
+ private readonly DirectoryInfo _scratchDirectory;
+
+ public NuGetKeyVaultSignerTests()
+ {
+ var directory = Path.Join(Path.GetTempPath(), "AzureSign.Core.Tests.NuGet", Guid.NewGuid().ToString("N"));
+ _scratchDirectory = Directory.CreateDirectory(directory);
+ }
+
+ [Theory]
+ [MemberData(nameof(RsaCertificatesAndDigests))]
+ public async Task ShouldSignNuGetPackage(string certificate, string digest)
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(certificate, "test", X509KeyStorageFlags.EphemeralKeySet);
+ using var signer = new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, new HashAlgorithmName(digest), TimeStampConfiguration.None);
+ var package = CreatePackage();
+ var originalEntries = GetEntries(package);
+ Assert.False(NuGetKeyVaultSigner.IsSigned(package));
+
+ var result = signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal(0, result);
+ Assert.True(NuGetKeyVaultSigner.IsSigned(package));
+ await AssertValidAuthorSignature(package, signingCert, originalEntries);
+ }
+
+ [Theory]
+ [MemberData(nameof(RsaCertificates))]
+ public async Task ShouldReplaceExistingSignature(string certificate)
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(certificate, "test", X509KeyStorageFlags.EphemeralKeySet);
+ using var signer = new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, HashAlgorithmName.SHA256, TimeStampConfiguration.None);
+ var package = CreatePackage();
+ var originalEntries = GetEntries(package);
+
+ Assert.Equal(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+ Assert.Equal(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+
+ await AssertValidAuthorSignature(package, signingCert, originalEntries);
+ }
+
+ [Theory]
+ [MemberData(nameof(RsaCertificates))]
+ public async Task ShouldSignNuGetPackageWithTimestamp(string certificate)
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(certificate, "test", X509KeyStorageFlags.EphemeralKeySet);
+ var timestampConfig = new TimeStampConfiguration("http://timestamp.digicert.com", HashAlgorithmName.SHA256, TimeStampType.RFC3161);
+ using var signer = new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, HashAlgorithmName.SHA256, timestampConfig);
+ var package = CreatePackage();
+ var originalEntries = GetEntries(package);
+
+ Assert.Equal(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+
+ var signature = await AssertValidAuthorSignature(package, signingCert, originalEntries);
+ var timestamp = Assert.Single(signature.Timestamps);
+ Assert.InRange(timestamp.GeneralizedTime, DateTimeOffset.UtcNow.AddHours(-1), DateTimeOffset.UtcNow.AddHours(1));
+ }
+
+ [Fact]
+ public void ShouldSignSymbolPackage()
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(Path.Combine("signcerts", "rsa-2048.pfx"), "test", X509KeyStorageFlags.EphemeralKeySet);
+ using var signer = new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, HashAlgorithmName.SHA256, TimeStampConfiguration.None);
+ var package = CreatePackage(".snupkg");
+
+ Assert.True(NuGetKeyVaultSigner.IsNuGetPackage(package));
+ Assert.Equal(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+ Assert.True(NuGetKeyVaultSigner.IsSigned(package));
+ }
+
+ [Fact]
+ public void ShouldRejectSha1FileDigest()
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(Path.Combine("signcerts", "rsa-2048.pfx"), "test", X509KeyStorageFlags.EphemeralKeySet);
+ Assert.Throws(() => new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, HashAlgorithmName.SHA1, TimeStampConfiguration.None));
+ }
+
+ [Fact]
+ public void ShouldRejectAuthenticodeTimestamp()
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(Path.Combine("signcerts", "rsa-2048.pfx"), "test", X509KeyStorageFlags.EphemeralKeySet);
+ var timestampConfig = new TimeStampConfiguration("http://timestamp.digicert.com", default, TimeStampType.Authenticode);
+ Assert.Throws(() => new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, HashAlgorithmName.SHA256, timestampConfig));
+ }
+
+ [Fact]
+ public void ShouldRejectECDsaKey()
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(Path.Combine("signcerts", "ecdsa-nist-p256.pfx"), "test", X509KeyStorageFlags.EphemeralKeySet);
+ Assert.Throws(() => new NuGetKeyVaultSigner(signingCert.GetECDsaPrivateKey()!, signingCert, HashAlgorithmName.SHA256, TimeStampConfiguration.None));
+ }
+
+ [Theory]
+ [MemberData(nameof(RsaCertificatesAndDigests))]
+ public async Task ShouldPassNuGetSignatureVerification(string certificate, string digest)
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(certificate, "test", X509KeyStorageFlags.EphemeralKeySet);
+ using var signer = new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, new HashAlgorithmName(digest), TimeStampConfiguration.None);
+ var package = CreatePackage();
+
+ Assert.Equal(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+
+ await AssertPassesNuGetVerification(package);
+ }
+
+ [Fact]
+ public void ShouldNotReportUnreadablePackageAsSigned()
+ {
+ var package = CreatePackage();
+
+ using (File.Open(package, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
+ {
+ Assert.False(NuGetKeyVaultSigner.IsSigned(package));
+ }
+ }
+
+ [Fact]
+ public async Task ShouldDetectSignatureWithUtf8Flag()
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(Path.Combine("signcerts", "rsa-2048.pfx"), "test", X509KeyStorageFlags.EphemeralKeySet);
+ using var signer = new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, HashAlgorithmName.SHA256, TimeStampConfiguration.None);
+ var package = CreatePackage();
+ var originalEntries = GetEntries(package);
+ Assert.Equal(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+
+ SetUtf8FlagOnSignatureEntry(package);
+
+ Assert.True(NuGetKeyVaultSigner.IsSigned(package));
+ Assert.Equal(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+ await AssertValidAuthorSignature(package, signingCert, originalEntries);
+ }
+
+ [Fact]
+ public async Task ShouldPreserveArchiveComment()
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(Path.Combine("signcerts", "rsa-2048.pfx"), "test", X509KeyStorageFlags.EphemeralKeySet);
+ using var signer = new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, HashAlgorithmName.SHA256, TimeStampConfiguration.None);
+ var package = CreatePackage(comment: "Archive comment");
+ var originalEntries = GetEntries(package);
+
+ Assert.Equal(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+
+ await AssertValidAuthorSignature(package, signingCert, originalEntries);
+ using var archive = ZipFile.OpenRead(package);
+ Assert.Equal("Archive comment", archive.Comment);
+ }
+
+ [Fact]
+ public void ShouldFailOnReadOnlyPackageWithoutChangingIt()
+ {
+ var signingCert = X509CertificateLoader.LoadPkcs12FromFile(Path.Combine("signcerts", "rsa-2048.pfx"), "test", X509KeyStorageFlags.EphemeralKeySet);
+ using var signer = new NuGetKeyVaultSigner(signingCert.GetRSAPrivateKey()!, signingCert, HashAlgorithmName.SHA256, TimeStampConfiguration.None);
+ var package = CreatePackage();
+ var originalBytes = File.ReadAllBytes(package);
+ File.SetAttributes(package, FileAttributes.ReadOnly);
+
+ try
+ {
+ Assert.NotEqual(0, signer.SignFile(package, cancellationToken: TestContext.Current.CancellationToken));
+ Assert.Equal(originalBytes, File.ReadAllBytes(package));
+ }
+ finally
+ {
+ File.SetAttributes(package, FileAttributes.Normal);
+ }
+ }
+
+ [Theory]
+ [InlineData("package.nupkg", true)]
+ [InlineData("package.NUPKG", true)]
+ [InlineData("package.snupkg", true)]
+ [InlineData("package.zip", false)]
+ [InlineData("package.exe", false)]
+ public void ShouldDetectNuGetPackageExtensions(string path, bool expected)
+ {
+ Assert.Equal(expected, NuGetKeyVaultSigner.IsNuGetPackage(path));
+ }
+
+ private static async Task AssertValidAuthorSignature(string package, X509Certificate2 signingCert, Dictionary originalEntries)
+ {
+ using (var reader = new PackageArchiveReader(package))
+ {
+ Assert.True(await reader.IsSignedAsync(TestContext.Current.CancellationToken));
+ var signature = await reader.GetPrimarySignatureAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(signature);
+ Assert.Equal(SignatureType.Author, signature.Type);
+ Assert.Equal(signingCert.Thumbprint, signature.SignerInfo.Certificate!.Thumbprint);
+
+ // Throws if the package content does not match the hash in the signature.
+ await reader.ValidateIntegrityAsync(signature.SignatureContent, TestContext.Current.CancellationToken);
+
+ var cms = new SignedCms();
+ cms.Decode(signature.GetBytes());
+ cms.CheckSignature(verifySignatureOnly: true);
+
+ // The original entries are intact and the only new entry is the signature file.
+ var entries = GetEntries(package);
+ Assert.Equal(originalEntries.Count + 1, entries.Count);
+ Assert.Contains(".signature.p7s", entries.Keys);
+ foreach (var (name, content) in originalEntries)
+ {
+ Assert.Equal(content, entries[name]);
+ }
+
+ return signature;
+ }
+ }
+
+ private static async Task AssertPassesNuGetVerification(string package)
+ {
+ // The test certificates are not trusted, so untrusted roots are allowed. Every other check is an error.
+ var settings = new SignedPackageVerifierSettings(
+ allowUnsigned: false,
+ allowIllegal: false,
+ allowUntrusted: true,
+ allowIgnoreTimestamp: true,
+ allowMultipleTimestamps: false,
+ allowNoTimestamp: true,
+ allowUnknownRevocation: true,
+ reportUnknownRevocation: false,
+ verificationTarget: VerificationTarget.All,
+ signaturePlacement: SignaturePlacement.Any,
+ repositoryCountersignatureVerificationBehavior: SignatureVerificationBehavior.IfExistsAndIsNecessary,
+ revocationMode: NuGet.Common.RevocationMode.Offline);
+ var verifier = new PackageSignatureVerifier(new ISignatureVerificationProvider[]
+ {
+ new IntegrityVerificationProvider(),
+ new SignatureTrustAndValidityVerificationProvider(),
+ });
+
+ using var reader = new PackageArchiveReader(package);
+ var result = await verifier.VerifySignaturesAsync(reader, settings, TestContext.Current.CancellationToken);
+ var errors = result.Results
+ .SelectMany(r => r.Issues)
+ .Where(issue => issue.Level == NuGet.Common.LogLevel.Error)
+ .Select(issue => $"{issue.Code}: {issue.Message}");
+
+ Assert.True(result.IsValid, string.Join(Environment.NewLine, errors));
+ }
+
+ private static void SetUtf8FlagOnSignatureEntry(string package)
+ {
+ const int LocalFileHeaderSignature = 0x04034b50;
+ const int CentralDirectoryHeaderSignature = 0x02014b50;
+ var bytes = File.ReadAllBytes(package);
+ var name = Encoding.ASCII.GetBytes(".signature.p7s");
+ var updated = 0;
+ var start = 0;
+ int found;
+
+ while ((found = bytes.AsSpan(start).IndexOf(name)) >= 0)
+ {
+ var i = start + found;
+ start = i + 1;
+
+ // The file name follows the fixed part of the local file header (30 bytes) or central directory header (46 bytes).
+ if (i >= 30 && BitConverter.ToInt32(bytes, i - 30) == LocalFileHeaderSignature)
+ {
+ bytes[i - 30 + 7] |= 0x08;
+ updated++;
+ }
+ else if (i >= 46 && BitConverter.ToInt32(bytes, i - 46) == CentralDirectoryHeaderSignature)
+ {
+ bytes[i - 46 + 9] |= 0x08;
+ updated++;
+ }
+ }
+
+ Assert.Equal(2, updated);
+ File.WriteAllBytes(package, bytes);
+ }
+
+ private static Dictionary GetEntries(string package)
+ {
+ using var archive = ZipFile.OpenRead(package);
+ var entries = new Dictionary();
+
+ foreach (var entry in archive.Entries)
+ {
+ using var stream = entry.Open();
+ using var buffer = new MemoryStream();
+ stream.CopyTo(buffer);
+ entries.Add(entry.FullName, buffer.ToArray());
+ }
+
+ return entries;
+ }
+
+ private string CreatePackage(string extension = ".nupkg", string comment = null)
+ {
+ var path = Path.Combine(_scratchDirectory.FullName, $"{Guid.NewGuid():N}{extension}");
+
+ using (var archive = ZipFile.Open(path, ZipArchiveMode.Create))
+ {
+ if (comment is not null)
+ {
+ archive.Comment = comment;
+ }
+
+ AddEntry(archive, "Test.Package.nuspec", """
+
+
+
+ Test.Package
+ 1.0.0
+ Test
+ Test package.
+
+
+ """);
+ AddEntry(archive, "lib/net8.0/Test.Package.dll", File.ReadAllText("signtarget.exe", Encoding.Latin1));
+ AddEntry(archive, "[Content_Types].xml", "");
+ }
+
+ return path;
+
+ static void AddEntry(ZipArchive archive, string name, string content)
+ {
+ var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
+ using var writer = new StreamWriter(entry.Open(), Encoding.Latin1);
+ writer.Write(content);
+ }
+ }
+
+ public void Dispose()
+ {
+ _scratchDirectory.Delete(true);
+ }
+
+ public static IEnumerable