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 RsaCertificates => + Directory.EnumerateFiles("signcerts", "rsa-*.pfx").Select(file => new object[] { file }); + + public static IEnumerable RsaCertificatesAndDigests + { + get + { + foreach (var file in Directory.EnumerateFiles("signcerts", "rsa-*.pfx")) + { + foreach (var digest in new[] { "SHA256", "SHA384", "SHA512" }) + { + yield return new object[] { file, digest }; + } + } + } + } + } +}