File: DeveloperCertificateService.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
#pragma warning disable ASPIRECERTIFICATES001
#pragma warning disable ASPIREFILESYSTEM001
 
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Collections.Immutable;
using System.Security.Cryptography.X509Certificates;
 
namespace Aspire.Hosting;
 
internal class DeveloperCertificateService : IDeveloperCertificateService
{
    private readonly Lazy<ImmutableList<X509Certificate2>> _certificates;
    private readonly Lazy<bool> _supportsContainerTrust;
    private readonly Lazy<bool> _supportsTlsTermination;
    private bool _latestCertificateIsUntrusted;
 
    public DeveloperCertificateService(ILogger<DeveloperCertificateService> logger, IConfiguration configuration, DistributedApplicationOptions options)
    {
        TrustCertificate = configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultTrust) ??
            options.TrustDeveloperCertificate ??
            true;
 
        _certificates = new Lazy<ImmutableList<X509Certificate2>>(() =>
        {
            try
            {
                using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
                store.Open(OpenFlags.ReadOnly);
 
                var now = DateTimeOffset.Now;
 
                // Get all valid ASP.NET Core development certificates.
                // Use .Where() instead of .Find() to preserve the original keychain-backed certificate
                // instances on macOS. Find() clones certificates which can invalidate keychain handles.
                var validCerts = FindDevCertificates(store, now).ToList();
 
                // If any certificate has a Subject Key Identifier extension, exclude certificates without it
                if (validCerts.Any(c => c.HasSubjectKeyIdentifier()))
                {
                    validCerts = validCerts.Where(c => c.HasSubjectKeyIdentifier()).ToList();
                }
 
                // Order by version and expiration date descending to get the most recent, highest version first.
                // OpenSSL will only check the first self-signed certificate in the bundle that matches a given domain,
                // so we want to ensure the certificate that will be used by ASP.NET Core is the first one in the bundle.
                // Match the ordering logic ASP.NET Core uses, including DateTimeOffset.Now for current time: https://github.com/dotnet/aspnetcore/blob/0aefdae365ff9b73b52961acafd227309524ce3c/src/Shared/CertificateGeneration/CertificateManager.cs#L122
                var bestCerts = validCerts
                    .GroupBy(c => c.Extensions.OfType<X509SubjectKeyIdentifierExtension>().FirstOrDefault()?.SubjectKeyIdentifier)
                    .SelectMany(g => g.OrderByVersion().Take(1))
                    .OrderByVersion()
                    .ToList();
 
                // Partition into trusted and untrusted using a single X509Chain instance.
                // RevocationMode is set to NoCheck since revocation doesn't apply to self-signed dev certs.
                using var chain = new X509Chain();
                chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
 
                // On Windows, chain.Build() can succeed even when the certificate isn't in the
                // trusted root store. Open the CurrentUser Root store so we can verify membership.
                X509Certificate2Collection? rootCerts = null;
                if (OperatingSystem.IsWindows())
                {
                    using var rootStore = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
                    rootStore.Open(OpenFlags.ReadOnly);
                    rootCerts = rootStore.Certificates;
                }
 
                // Find the dev certs that are trusted
                var trustedCerts = new List<X509Certificate2>();
                foreach (var cert in bestCerts)
                {
                    try
                    {
                        if (!chain.Build(cert))
                        {
                            continue;
                        }
 
                        // On Windows, also verify the certificate exists in the root store
                        if (rootCerts is not null &&
                            !rootCerts.Any(rc => rc.RawDataMemory.Span.SequenceEqual(cert.RawDataMemory.Span)))
                        {
                            continue;
                        }
 
                        trustedCerts.Add(cert);
                    }
                    finally
                    {
                        // Reset the chain for the next certificate regardless of branch taken.
                        chain.Reset();
                    }
                }
 
                // Dispose root store certificates after use
                if (rootCerts is not null)
                {
                    foreach (var rc in rootCerts)
                    {
                        rc.Dispose();
                    }
                }
 
                // Flag if the newest/highest-version cert is not trusted
                if (bestCerts.Count > 0 &&
                    (trustedCerts.Count == 0 || trustedCerts[0].Thumbprint != bestCerts[0].Thumbprint))
                {
                    _latestCertificateIsUntrusted = true;
                }
 
                // Release the unused certificates
                foreach (var unusedCert in validCerts.Except(trustedCerts))
                {
                    unusedCert.Dispose();
                }
 
                if (trustedCerts.Count == 0)
                {
                    return ImmutableList<X509Certificate2>.Empty;
                }
 
                return trustedCerts.ToImmutableList();
            }
            catch (Exception ex)
            {
                logger.LogWarning("Failed to load developer certificates from the CurrentUser/My certificate store. Automatic trust of development certificates will not be available. Reason: {Message}", ex.Message);
                return ImmutableList<X509Certificate2>.Empty;
            }
        });
 
        _supportsContainerTrust = new Lazy<bool>(() =>
        {
            var containerTrustAvailable = Certificates.Any(c => c.GetCertificateVersion() >= X509Certificate2Extensions.MinimumCertificateVersionSupportingContainerTrust);
            logger.LogDebug("Container trust for developer certificates is {Status}.", containerTrustAvailable ? "available" : "not available");
            return containerTrustAvailable;
        });
 
        _supportsTlsTermination = new Lazy<bool>(() =>
        {
            var supportsTlsTermination = Certificates.Any(c => c.HasPrivateKey);
            logger.LogDebug("Developer certificate HTTPS/TLS termination support: {Available}", supportsTlsTermination);
            return supportsTlsTermination;
        });
 
        // By default, only use for server authentication if trust is also enabled (and a developer certificate with a private key is available)
        UseForHttps = (configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultHttpsTermination) ??
            options.DeveloperCertificateDefaultHttpsTerminationEnabled ??
            true) && TrustCertificate && _supportsTlsTermination.Value;
    }
 
    /// <inheritdoc />
    public ImmutableList<X509Certificate2> Certificates => _certificates.Value;
 
    /// <inheritdoc />
    public bool SupportsContainerTrust => _supportsContainerTrust.Value;
 
    /// <inheritdoc />
    public bool TrustCertificate { get; }
 
    /// <inheritdoc />
    public bool UseForHttps { get; }
 
    /// <summary>
    /// Gets a value indicating whether a newer ASP.NET Core development certificate was detected
    /// that is not in the trusted set. This is true when the highest-version/most-recent dev cert
    /// is not trusted, even though older trusted certs may exist.
    /// </summary>
    internal bool LatestCertificateIsUntrusted
    {
        get
        {
            _ = _certificates.Value; // Ensure certificates have been evaluated
            return _latestCertificateIsUntrusted;
        }
    }
 
    /// <summary>
    /// Finds ASP.NET Core development certificates in the store, filtered by date validity and private key presence.
    /// </summary>
    private static IEnumerable<X509Certificate2> FindDevCertificates(X509Store store, DateTimeOffset now)
    {
        return store.Certificates
            .Where(c => c.IsAspNetCoreDevelopmentCertificate())
            .Where(c => c.NotBefore <= now && now <= c.NotAfter)
            .Where(c => c.HasPrivateKey);
    }
}