File: Utils\EnvironmentChecker\DevCertsCheck.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json.Nodes;
using Aspire.Cli.Certificates;
using Aspire.Cli.DotNet;
using Aspire.Cli.Resources;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Utils.EnvironmentChecker;
 
/// <summary>
/// Checks if the HTTPS development certificate is trusted and detects multiple certificates.
/// </summary>
internal sealed class DevCertsCheck(ILogger<DevCertsCheck> logger, ICertificateToolRunner certificateToolRunner, IEnvironment environment, IProcessExecutionFactory processExecutionFactory) : IEnvironmentCheck
{
    internal const string CheckName = "dev-certs";
    internal const string VersionCheckName = "dev-certs-version";
    internal const string CertUtilCheckName = "dev-certs-certutil";
    internal const string OpenSslCertificateCacheCheckName = "dev-certs-openssl-cache";
    private const string OpenSslCommand = "openssl";
    private const int OpenSslHashCollisionSearchLimit = 10;
 
    public int Order => 35; // After SDK check (30), before container checks (40+)
 
    private static readonly string s_trustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsTrustFixFormat, "aspire certs trust");
    private static readonly string s_cleanAndTrustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsCleanAndTrustFixFormat, "aspire certs clean", "aspire certs trust");
    private static readonly string s_installOpenSslCleanAndTrustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsInstallOpenSslCleanAndTrustFixFormat, "openssl", "aspire certs clean", "aspire certs trust");
 
    public async Task<IReadOnlyList<EnvironmentCheckResult>> CheckAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            var trustResult = certificateToolRunner.CheckHttpCertificate(cancellationToken);
            var results = EvaluateCertificateResults(trustResult.Certificates, environment);
            await AddLinuxOpenSslCertificateCacheWarningsAsync(results, trustResult.Certificates, environment, cancellationToken).ConfigureAwait(false);
            AddLinuxCertificateToolWarnings(results, environment);
 
            return results;
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Error checking dev-certs");
            return [new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Environment,
                Name = CheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = "Unable to check HTTPS development certificate",
                Details = ex.Message
            }];
        }
    }
 
    /// <summary>
    /// Evaluates certificate information and produces the appropriate check results.
    /// </summary>
    /// <param name="certInfos">Certificate information from <see cref="ICertificateToolRunner.CheckHttpCertificate"/>.</param>
    /// <param name="environment">The environment abstraction for reading environment variables.</param>
    /// <returns>The list of environment check results.</returns>
    internal static List<EnvironmentCheckResult> EvaluateCertificateResults(
        IReadOnlyList<DevCertInfo> certInfos, IEnvironment environment)
    {
        if (certInfos.Count == 0)
        {
            return [new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Environment,
                Name = CheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = DoctorCommandStrings.DevCertsNoCertificateMessage,
                Details = DoctorCommandStrings.DevCertsNoCertificateDetails,
                Fix = s_trustFixCommand,
                Link = "https://aka.ms/aspire-prerequisites#dev-certs"
            }];
        }
 
        var trustedCount = certInfos.Count(c => c.TrustLevel != CertificateManager.TrustLevel.None);
        var fullyTrustedCount = certInfos.Count(c => c.TrustLevel == CertificateManager.TrustLevel.Full);
        var partiallyTrustedCount = certInfos.Count(c => c.TrustLevel == CertificateManager.TrustLevel.Partial);
 
        // Check for old certificate versions among trusted certificates
        var oldTrustedVersions = certInfos
            .Where(c => c.TrustLevel != CertificateManager.TrustLevel.None && c.Version < CertificateManager.CurrentAspNetCoreCertificateVersion)
            .Select(c => c.Version)
            .ToList();
 
        var metadata = BuildCertificateMetadata(certInfos);
        var results = new List<EnvironmentCheckResult>();
 
        // Check for multiple dev certificates (in My store)
        if (certInfos.Count > 1)
        {
            var certDetails = string.Join(", ", certInfos.Select(c =>
            {
                var trustLabel = c.TrustLevel switch
                {
                    CertificateManager.TrustLevel.Full => $" {DoctorCommandStrings.DevCertsTrustLabelFull}",
                    CertificateManager.TrustLevel.Partial => $" {DoctorCommandStrings.DevCertsTrustLabelPartial}",
                    _ => ""
                };
                return $"v{c.Version} ({c.Thumbprint?[..8]}...){trustLabel}";
            }));
 
            if (trustedCount == 0)
            {
                results.Add(new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.Environment,
                    Name = CheckName,
                    Status = EnvironmentCheckStatus.Warning,
                    Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsMultipleNoneTrustedMessageFormat, certInfos.Count),
                    Details = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsMultipleNoneTrustedDetailsFormat, certDetails),
                    Fix = s_cleanAndTrustFixCommand,
                    Link = "https://aka.ms/aspire-prerequisites#dev-certs",
                    Metadata = metadata
                });
            }
            else if (trustedCount < certInfos.Count)
            {
                results.Add(new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.Environment,
                    Name = CheckName,
                    Status = EnvironmentCheckStatus.Warning,
                    Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsMultipleSomeUntrustedMessageFormat, certInfos.Count),
                    Details = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsMultipleSomeUntrustedDetailsFormat, certDetails),
                    Fix = s_cleanAndTrustFixCommand,
                    Link = "https://aka.ms/aspire-prerequisites#dev-certs",
                    Metadata = metadata
                });
            }
            // else: all certificates are trusted — no warning needed
            else
            {
                results.Add(new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.Environment,
                    Name = CheckName,
                    Status = EnvironmentCheckStatus.Pass,
                    Message = DoctorCommandStrings.DevCertsTrustedMessage,
                    Metadata = metadata
                });
            }
        }
        else if (trustedCount == 0)
        {
            // Single certificate that's not trusted - provide diagnostic info
            var cert = certInfos[0];
            results.Add(new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Environment,
                Name = CheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = DoctorCommandStrings.DevCertsNotTrustedMessage,
                Details = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsNotTrustedDetailsFormat, cert.Thumbprint ?? "unknown"),
                Fix = s_trustFixCommand,
                Link = "https://aka.ms/aspire-prerequisites#dev-certs",
                Metadata = metadata
            });
        }
        else if (partiallyTrustedCount > 0 && fullyTrustedCount == 0)
        {
            // Certificate is partially trusted (Linux with SSL_CERT_DIR not configured)
            var devCertsTrustPath = CertificateHelpers.GetDevCertsTrustPath(environment);
            results.Add(new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Environment,
                Name = CheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = DoctorCommandStrings.DevCertsPartiallyTrustedMessage,
                Details = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsPartiallyTrustedDetailsFormat, devCertsTrustPath),
                Fix = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsPartiallyTrustedFixFormat, BuildSslCertDirFixCommand(devCertsTrustPath, environment)),
                Link = "https://aka.ms/aspire-prerequisites#dev-certs",
                Metadata = metadata
            });
        }
        else
        {
            // Trusted certificate - success case
            results.Add(new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Environment,
                Name = CheckName,
                Status = EnvironmentCheckStatus.Pass,
                Message = DoctorCommandStrings.DevCertsTrustedMessage,
                Metadata = metadata
            });
        }
 
        // Warn about old certificate versions
        if (oldTrustedVersions.Count > 0)
        {
            var versions = string.Join(", ", oldTrustedVersions.Select(v => $"v{v}"));
            results.Add(new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Environment,
                Name = VersionCheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOldVersionMessageFormat, versions),
                Details = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOldVersionDetailsFormat, CertificateManager.CurrentMinimumAspNetCoreCertificateVersion),
                Fix = s_cleanAndTrustFixCommand,
                Link = "https://aka.ms/aspire-prerequisites#dev-certs"
            });
        }
 
        return results;
    }
 
    private async Task AddLinuxOpenSslCertificateCacheWarningsAsync(List<EnvironmentCheckResult> results, IReadOnlyList<DevCertInfo> certInfos, IEnvironment environment, CancellationToken cancellationToken)
    {
        if (!environment.IsLinux())
        {
            return;
        }
 
        var currentCertificates = GetCurrentDevCertificates(certInfos).ToList();
        if (currentCertificates.Count == 0)
        {
            return;
        }
 
        var trustPath = CertificateHelpers.GetDevCertsTrustPath(environment);
        var environmentVariables = GetEnvironmentVariables(environment);
        PathLookupHelper.TryResolveExecutablePath(OpenSslCommand, out var openSslPath, environmentVariables);
 
        var cacheStatus = await EvaluateOpenSslCertificateCacheAsync(trustPath, currentCertificates, openSslPath, cancellationToken).ConfigureAwait(false);
        if (cacheStatus is null)
        {
            return;
        }
 
        results.Add(new EnvironmentCheckResult
        {
            Category = EnvironmentCheckCategories.Environment,
            Name = OpenSslCertificateCacheCheckName,
            Status = EnvironmentCheckStatus.Warning,
            Message = cacheStatus.Message,
            Details = cacheStatus.Details,
            Fix = cacheStatus.Fix,
            Link = "https://aka.ms/aspire-prerequisites#dev-certs"
        });
    }
 
    private static IEnumerable<DevCertInfo> GetCurrentDevCertificates(IReadOnlyList<DevCertInfo> certInfos)
    {
        var now = DateTimeOffset.Now;
        return certInfos
            .Where(c => c.IsHttpsDevelopmentCertificate &&
                c.ValidityNotBefore <= now &&
                now <= c.ValidityNotAfter &&
                !string.IsNullOrEmpty(c.Thumbprint))
            .OrderByDescending(c => c.Version)
            .ThenByDescending(c => c.ValidityNotAfter)
            .ThenBy(c => c.Thumbprint, StringComparer.OrdinalIgnoreCase);
    }
 
    private async Task<OpenSslCertificateCacheStatus?> EvaluateOpenSslCertificateCacheAsync(string trustPath, IReadOnlyList<DevCertInfo> currentCertificates, string? openSslPath, CancellationToken cancellationToken)
    {
        var fix = GetOpenSslCertificateCacheFix(openSslPath);
 
        if (!Directory.Exists(trustPath))
        {
            var trustedThumbprints = GetTrustedThumbprints(currentCertificates);
            if (trustedThumbprints.Count == 0)
            {
                return null;
            }
 
            return new(
                DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage,
                string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", trustedThumbprints)),
                fix);
        }
 
        var unreadableFiles = new List<string>();
        var mismatchedThumbprints = new List<string>();
        var missingTrustedThumbprints = new List<string>();
        var missingHashLinkThumbprints = new List<string>();
 
        foreach (var certificate in currentCertificates)
        {
            var certificateFileName = GetOpenSslCertificateFileName(certificate.Thumbprint!);
            var certificateFile = Path.Combine(trustPath, certificateFileName);
            if (!File.Exists(certificateFile))
            {
                if (certificate.TrustLevel != CertificateManager.TrustLevel.None)
                {
                    missingTrustedThumbprints.Add(certificate.Thumbprint!);
                }
 
                continue;
            }
 
            try
            {
                using var cachedCertificate = X509CertificateLoader.LoadCertificateFromFile(certificateFile);
                if (!string.Equals(certificate.Thumbprint, cachedCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase))
                {
                    mismatchedThumbprints.Add(certificate.Thumbprint!);
                }
                else if (certificate.TrustLevel != CertificateManager.TrustLevel.None &&
                    !await HasOpenSslHashEntryAsync(trustPath, certificateFile, cachedCertificate, openSslPath, cancellationToken).ConfigureAwait(false))
                {
                    missingHashLinkThumbprints.Add(certificate.Thumbprint!);
                }
            }
            catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
            {
                unreadableFiles.Add(certificateFileName);
            }
        }
 
        if (unreadableFiles.Count > 0)
        {
            return new(
                DoctorCommandStrings.DevCertsOpenSslCacheUnreadableMessage,
                string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheUnreadableFilesDetailsFormat, trustPath, string.Join(", ", unreadableFiles)),
                fix);
        }
 
        if (mismatchedThumbprints.Count > 0)
        {
            return new(
                DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage,
                string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", mismatchedThumbprints)),
                fix);
        }
 
        if (missingTrustedThumbprints.Count > 0)
        {
            return new(
                DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage,
                string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", missingTrustedThumbprints)),
                fix);
        }
 
        if (missingHashLinkThumbprints.Count > 0)
        {
            return new(
                DoctorCommandStrings.DevCertsOpenSslCacheMissingHashLinkMessage,
                string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingHashLinkDetailsFormat, trustPath, string.Join(", ", missingHashLinkThumbprints)),
                fix);
        }
 
        return null;
    }
 
    private static string GetOpenSslCertificateCacheFix(string? openSslPath) =>
        openSslPath is null ? s_installOpenSslCleanAndTrustFixCommand : s_cleanAndTrustFixCommand;
 
    private async Task<bool> HasOpenSslHashEntryAsync(string trustPath, string certificateFile, X509Certificate2 certificate, string? openSslPath, CancellationToken cancellationToken)
    {
        if (openSslPath is not null)
        {
            var (success, hash) = await TryGetOpenSslHashAsync(openSslPath, certificateFile, cancellationToken).ConfigureAwait(false);
            return success &&
                HasMatchingHashEntry(trustPath, hash, certificate);
        }
 
        // Without openssl we cannot compute the subject hash that OpenSSL requires, so
        // friendly-name PEM checks are the strongest validation we can perform.
        return true;
    }
 
    private static bool HasMatchingHashEntry(string trustPath, string hash, X509Certificate2 certificate)
    {
        for (var i = 0; i < OpenSslHashCollisionSearchLimit; i++)
        {
            var hashEntryPath = Path.Combine(trustPath, $"{hash}.{i}");
            if (CertificateFileMatches(hashEntryPath, certificate))
            {
                return true;
            }
        }
 
        return false;
    }
 
    private static bool CertificateFileMatches(string certificateFile, X509Certificate2 certificate)
    {
        if (!File.Exists(certificateFile))
        {
            return false;
        }
 
        try
        {
            using var cachedCertificate = X509CertificateLoader.LoadCertificateFromFile(certificateFile);
 
            return string.Equals(certificate.Thumbprint, cachedCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase);
        }
        catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
        {
            return false;
        }
    }
 
    private async Task<(bool Success, string Hash)> TryGetOpenSslHashAsync(string openSslPath, string certificateFile, CancellationToken cancellationToken)
    {
        var stdout = new StringBuilder();
        var workingDirectory = Path.GetDirectoryName(certificateFile) is { } directory
            ? new DirectoryInfo(directory)
            : new DirectoryInfo(Directory.GetCurrentDirectory());
        await using var process = processExecutionFactory.CreateExecution(
            openSslPath,
            ["x509", "-hash", "-noout", "-in", certificateFile],
            env: null,
            workingDirectory,
            new ProcessInvocationOptions
            {
                SuppressLogging = true,
                StandardOutputCallback = line => stdout.AppendLine(line),
                StandardErrorCallback = _ => { },
            });
        var started = false;
 
        try
        {
            started = await process.StartAsync(cancellationToken).ConfigureAwait(false);
            if (!started)
            {
                return (false, "");
            }
 
            var exitCode = await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            if (exitCode != 0)
            {
                return (false, "");
            }
 
            var hash = stdout.ToString().Trim();
            return hash.Length > 0 ? (true, hash) : (false, "");
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            if (started)
            {
                TryKillProcess(process);
            }
 
            throw;
        }
        catch
        {
            if (started)
            {
                TryKillProcess(process);
            }
 
            return (false, "");
        }
    }
 
    private static void TryKillProcess(IProcessExecution process)
    {
        try
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
        }
        catch (Exception)
        {
            // Process cleanup is best-effort; do not let a kill failure mask caller cancellation
            // or turn a non-critical OpenSSL probe failure into a failed doctor check.
        }
    }
 
    private static List<string> GetTrustedThumbprints(IReadOnlyList<DevCertInfo> currentCertificates)
    {
        return currentCertificates
            .Where(c => c.TrustLevel != CertificateManager.TrustLevel.None)
            .Select(c => c.Thumbprint!)
            .ToList();
    }
 
    private static string GetOpenSslCertificateFileName(string certificateThumbprint) =>
        $"aspnetcore-localhost-{certificateThumbprint}.pem";
 
    private static void AddLinuxCertificateToolWarnings(List<EnvironmentCheckResult> results, IEnvironment environment)
    {
        if (!environment.IsLinux())
        {
            return;
        }
 
        var environmentVariables = GetEnvironmentVariables(environment);
 
        if (PathLookupHelper.TryResolveExecutablePath(CertificateHelpers.CertUtilCommand, out _, environmentVariables))
        {
            return;
        }
 
        results.Add(new EnvironmentCheckResult
        {
            Category = EnvironmentCheckCategories.Environment,
            Name = CertUtilCheckName,
            Status = EnvironmentCheckStatus.Warning,
            Message = DoctorCommandStrings.DevCertsMissingCertUtilMessage,
            Details = DoctorCommandStrings.DevCertsMissingCertUtilDetails,
            Fix = DoctorCommandStrings.DevCertsMissingCertUtilFix,
            Link = "https://aka.ms/aspire-prerequisites#dev-certs"
        });
    }
 
    private static Dictionary<string, string> GetEnvironmentVariables(IEnvironment environment) =>
        environment.GetEnvironmentVariables()
            .Where(kv => kv.Value is not null)
            .ToDictionary(kv => kv.Name, kv => kv.Value!);
 
    /// <summary>
    /// Builds structured metadata from certificate information for JSON output.
    /// </summary>
    private static JsonObject BuildCertificateMetadata(IReadOnlyList<DevCertInfo> certInfos)
    {
        var certificatesArray = new JsonArray();
        foreach (var cert in certInfos)
        {
            var certNode = new JsonObject
            {
                ["thumbprint"] = cert.Thumbprint ?? "unknown",
                ["version"] = cert.Version,
                ["trustLevel"] = cert.TrustLevel.ToString().ToLowerInvariant(),
                ["notBefore"] = cert.ValidityNotBefore.ToString("o", CultureInfo.InvariantCulture),
                ["notAfter"] = cert.ValidityNotAfter.ToString("o", CultureInfo.InvariantCulture)
            };
            certificatesArray.Add((JsonNode)certNode);
        }
 
        return new JsonObject
        {
            ["certificates"] = certificatesArray
        };
    }
 
    /// <summary>
    /// Builds the appropriate shell command for fixing SSL_CERT_DIR configuration.
    /// </summary>
    /// <remarks>
    /// <para>
    /// When <c>SSL_CERT_DIR</c> is already set, only the dev-certs trust path is appended
    /// (preserving the existing value via <c>$SSL_CERT_DIR</c> shell expansion). When it is
    /// not set, the command includes system certificate directories so they are not lost.
    /// </para>
    /// <para>
    /// Includes system certificate directories detected via OpenSSL or well-known fallback
    /// locations, matching the behavior of <see cref="Aspire.Cli.Certificates.CertificateService"/>.
    /// </para>
    /// </remarks>
    private static string BuildSslCertDirFixCommand(string devCertsTrustPath, IEnvironment environment)
    {
        var currentSslCertDir = environment.GetEnvironmentVariable("SSL_CERT_DIR");
 
        if (!string.IsNullOrEmpty(currentSslCertDir))
        {
            // SSL_CERT_DIR is already set — just append the dev-certs trust path.
            // Preserve the existing value via $SSL_CERT_DIR shell expansion.
            return $"export SSL_CERT_DIR=\"$SSL_CERT_DIR:{devCertsTrustPath}\"";
        }
 
        // SSL_CERT_DIR is not set — include system cert directories so they aren't lost.
        var systemCertDirs = CertificateHelpers.GetSystemCertificateDirectories();
        systemCertDirs.Add(devCertsTrustPath);
 
        // We still prepend $SSL_CERT_DIR to be safe in case the user makes later modifications to their environment
        return $"export SSL_CERT_DIR=\"$SSL_CERT_DIR:{string.Join(':', systemCertDirs)}\"";
    }
 
    private sealed record OpenSslCertificateCacheStatus(string Message, string Details, string Fix);
}