File: SignCommand\CertificateProvider.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\NuGet.Commands\NuGet.Commands.csproj (NuGet.Commands)
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using NuGet.Common;
using NuGet.Packaging.Signing;

namespace NuGet.Commands
{
    internal static class CertificateProvider
    {
        // "The system cannot find the file specified." (ERROR_FILE_NOT_FOUND)
        private const int ERROR_FILE_NOT_FOUND_HRESULT = unchecked((int)0x80070002);

        // OpenSSL:  error:2006D080:BIO routines:BIO_new_file:no such file
        private const int OPENSSL_BIO_R_NO_SUCH_FILE = 0x2006D080;

        // "The specified password is not correct." (ERROR_INVALID_PASSWORD)
        private const int ERROR_INVALID_PASSWORD_HRESULT = unchecked((int)0x80070056);

        // OpenSSL:  error:23076071:PKCS12 routines:PKCS12_parse:mac verify failure
        private const int OPENSSL_PKCS12_R_MAC_VERIFY_FAILURE = 0x23076071;
        private const int MACOS_PKCS12_MAC_VERIFY_FAILURE = -25264;

        // "The specified certificate file is not correct." (CRYPT_E_NO_MATCH)
        private const int CRYPT_E_NO_MATCH_HRESULT = unchecked((int)0x80092009);

        private const int MACOS_INVALID_CERT = -25257;

        private const int CRYPT_E_BAD_DECODE = unchecked((int)0x80092002);

#if IS_CORECLR
        //Generic exception ASN1 corrupted data
        private const int OPENSSL_ASN1_CORRUPTED_DATA_ERROR = unchecked((int)0x80131501);
#else
        // OpenSSL:  error:0D07803A:asn1 encoding routines:ASN1_ITEM_EX_D2I:nested asn1 error
        private const int OPENSSL_ERR_R_NESTED_ASN1_ERROR = 0x0D07803A;
#endif

        /// <summary>
        /// Looks for X509Certificates using the CertificateSourceOptions.
        /// Throws an InvalidOperationException if the option specifies a CertificateFilePath with invalid password.
        /// </summary>
        /// <param name="options">CertificateSourceOptions to be used while searching for the certificates.</param>
        /// <returns>An X509Certificate2Collection object containing matching certificates.
        /// If no matching certificates are found then it returns an empty collection.</returns>
        public static async Task<X509Certificate2Collection> GetCertificatesAsync(CertificateSourceOptions options)
        {
            // check certificate path
            var resultCollection = new X509Certificate2Collection();
            if (!string.IsNullOrEmpty(options.CertificatePath))
            {
                try
                {
                    var cert = await LoadCertificateFromFileAsync(options);

                    resultCollection = new X509Certificate2Collection(cert);
                }
                catch (CryptographicException ex)
                {
                    switch (ex.HResult)
                    {
                        case ERROR_INVALID_PASSWORD_HRESULT:
                        case OPENSSL_PKCS12_R_MAC_VERIFY_FAILURE:
                        case MACOS_PKCS12_MAC_VERIFY_FAILURE:
                            throw new SignCommandException(
                                LogMessage.CreateError(NuGetLogCode.NU3001,
                                string.Format(CultureInfo.CurrentCulture,
                                Strings.SignCommandInvalidPasswordException,
                                options.CertificatePath,
                                nameof(options.CertificatePassword))));

                        case ERROR_FILE_NOT_FOUND_HRESULT:
                        case OPENSSL_BIO_R_NO_SUCH_FILE:
                            throw new SignCommandException(
                                LogMessage.CreateError(NuGetLogCode.NU3001,
                                string.Format(CultureInfo.CurrentCulture,
                                    Strings.SignCommandCertificateFileNotFound,
                                    options.CertificatePath)));

                        case CRYPT_E_NO_MATCH_HRESULT:
                        case CRYPT_E_BAD_DECODE:
#if IS_CORECLR
                        case OPENSSL_ASN1_CORRUPTED_DATA_ERROR:
#else
                        case OPENSSL_ERR_R_NESTED_ASN1_ERROR:
#endif
                        case MACOS_INVALID_CERT:
                            throw new SignCommandException(
                                LogMessage.CreateError(NuGetLogCode.NU3001,
                                string.Format(CultureInfo.CurrentCulture,
                                    Strings.SignCommandInvalidCertException,
                                    options.CertificatePath)));

                        default:
                            throw;
                    }
                }
                catch (FileNotFoundException)
                {
                    throw new SignCommandException(
                            LogMessage.CreateError(NuGetLogCode.NU3001,
                            string.Format(CultureInfo.CurrentCulture,
                                Strings.SignCommandCertificateFileNotFound,
                                options.CertificatePath)));
                }
            }
            else
            {
                resultCollection = LoadCertificateFromStore(options);
            }

            return resultCollection;
        }

        private static
#if IS_DESKTOP
            async
#endif
            Task<X509Certificate2> LoadCertificateFromFileAsync(CertificateSourceOptions options)
        {
            X509Certificate2 cert;

            if (!string.IsNullOrEmpty(options.CertificatePassword))
            {
                // use the password if the user provided it
#if NET9_0_OR_GREATER
                cert = X509CertificateLoader.LoadPkcs12FromFile(options.CertificatePath, options.CertificatePassword);
#else
                cert = new X509Certificate2(options.CertificatePath, options.CertificatePassword);
#endif
            }
            else
            {
#if IS_DESKTOP
                try
                {
                    cert = new X509Certificate2(options.CertificatePath);
                }
                catch (CryptographicException ex)
                {
                    // prompt user for password if needed
                    if (ex.HResult == ERROR_INVALID_PASSWORD_HRESULT &&
                        !options.NonInteractive)
                    {
                        using (var password = await options.PasswordProvider.GetPassword(options.CertificatePath, options.Token))
                        {
                            cert = new X509Certificate2(options.CertificatePath, password);
                        }
                    }
                    else
                    {
                        throw;
                    }
                }
#else
#if NET9_0_OR_GREATER
                cert = X509CertificateLoader.LoadPkcs12FromFile(options.CertificatePath, null);
#else
                cert = new X509Certificate2(options.CertificatePath);
#endif
#endif
            }

#if IS_DESKTOP
            return cert;
#else
            return Task.FromResult(cert);
#endif
        }

        private static X509Certificate2Collection LoadCertificateFromStore(CertificateSourceOptions options)
        {
            X509Certificate2Collection resultCollection = new();

            using var store = new X509Store(options.StoreName, options.StoreLocation);

            OpenStore(store);

            // Passing true for validOnly seems like a good idea; it would filter out invalid certificates.
            // However, "invalid certificates" is a broad category that includes untrusted self-issued certificates.
            // Untrusted self-issued certificates are permitted at signing time, so we must perform certificate
            // validity checks ourselves.
            const bool validOnly = false;

            if (!string.IsNullOrEmpty(options.Fingerprint) &&
                CertificateUtility.TryDeduceHashAlgorithm(options.Fingerprint, out Common.HashAlgorithmName hashAlgorithmName))
            {
                if (hashAlgorithmName == Common.HashAlgorithmName.SHA1)
                {
                    resultCollection = store.Certificates.Find(X509FindType.FindByThumbprint, options.Fingerprint, validOnly);
                }
                else if (hashAlgorithmName != Common.HashAlgorithmName.Unknown)
                {
                    foreach (var cert in store.Certificates)
                    {
                        string actualFingerprint = CertificateUtility.GetHashString(cert, hashAlgorithmName);

                        if (string.Equals(actualFingerprint, options.Fingerprint, StringComparison.InvariantCultureIgnoreCase))
                        {
                            resultCollection.Add(cert);
                            break;
                        }
                    }

                }
            }
            else if (!string.IsNullOrEmpty(options.SubjectName))
            {
                resultCollection = store.Certificates.Find(X509FindType.FindBySubjectName, options.SubjectName, validOnly);
            }

            store.Close();

            resultCollection = GetValidCertificates(resultCollection, options.AllowUntrustedRoot);

            return resultCollection;
        }

        /// <summary>
        /// Opens an X509Store with read only access.
        /// Throws an InvalidOperationException if the store does not exist.
        /// </summary>
        /// <param name="store">X509Store to be opened.</param>
        private static void OpenStore(X509Store store)
        {
            try
            {
                store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
            }
            catch (CryptographicException ex)
            {
                if (ex.HResult == ERROR_FILE_NOT_FOUND_HRESULT)
                {
                    throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture,
                        Strings.SignCommandCertificateStoreNotFound,
                        store));
                }
            }
        }

        private static X509Certificate2Collection GetValidCertificates(X509Certificate2Collection certificates, bool allowUntrustedRoot = false)
        {
            var validCertificates = new X509Certificate2Collection();

            foreach (var certificate in certificates)
            {
                if (IsValid(certificate, certificates, allowUntrustedRoot))
                {
                    validCertificates.Add(certificate);
                }
            }

            return validCertificates;
        }

        private static bool IsValid(X509Certificate2 certificate, X509Certificate2Collection extraStore, bool allowUntrustedRoot = false)
        {
            try
            {
                using (var chain = CertificateChainUtility.GetCertificateChain(
                    certificate,
                    extraStore,
                    NullLogger.Instance,
                    CertificateType.Signature,
                    allowUntrustedRoot))
                {
                    return chain != null && chain.Count > 0;
                }
            }
            catch (SignatureException)
            {
                return false;
            }
        }
    }
}