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 Aspire.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
 
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();
 
                var trustedCerts = bestCerts.GetTrustedCertificates();
 
                // 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);
    }
 
    // Well-known location on disk where dev-cert key material is cached on macOS.
    private static readonly string s_userDevCertificateLocation = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire", "dev-certs", "https");
 
    private static readonly SemaphoreSlim s_certificateCacheSemaphore = new(1, 1);
 
    /// <summary>
    /// Returns the certificate PEM format key and/or PFX bytes for the specified certificate.
    /// On macOS, both outputs are cached as separate files to avoid triggering repeated
    /// keychain prompts. The cache is read without loading any PFX into an X509Certificate2,
    /// because EphemeralKeySet is not supported on macOS with net8.0 and loading without it
    /// imports the private key into the keychain.
    /// </summary>
    /// <param name="certificate">The certificate to export key material from.</param>
    /// <param name="password">The password for the private key, or <c>null</c> for unencrypted export.</param>
    /// <param name="needKeyPem">Whether to export the private key in PEM format.</param>
    /// <param name="needPfx">Whether to export the certificate in PFX format.</param>
    /// <param name="cancellationToken">A token that can be used to cancel the operation.</param>
    /// <returns>A tuple containing the PEM-encoded key and PFX bytes.</returns>
    internal static async Task<(char[]? keyPem, byte[]? pfxBytes)> GetKeyMaterialAsync(
        X509Certificate2 certificate,
        string? password,
        bool needKeyPem,
        bool needPfx,
        CancellationToken cancellationToken)
    {
        if (!needKeyPem && !needPfx)
        {
            return (null, null);
        }
 
        // This is a user managed certificate, not an asp.net core style dev cert
        if (!certificate.IsAspNetCoreDevelopmentCertificate())
        {
            return ExportFromPrivateKey(certificate, password, needKeyPem, needPfx);
        }
 
        // For dev certs we prefer reading from cache to avoid repeated keychain access prompts.
        // Ensure only one thread at a time is resolving certificates to avoid concurrent cache misses
        // all trying to update the cache at the same time.
        await s_certificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            var cached = EnsureCachedKeyMaterial(certificate, password);
            return (
                needKeyPem ? Encoding.UTF8.GetString(cached.keyBytes).ToCharArray() : null,
                needPfx ? cached.pfxBytes : null);
        }
        finally
        {
            s_certificateCacheSemaphore.Release();
        }
    }
 
    /// <summary>
    /// Ensures the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files
    /// exist for the specified ASP.NET Core developer certificate and returns the paths along with
    /// the certificate thumbprint. Returns <c>(null, null, null)</c> if the supplied certificate is
    /// not a developer certificate, has no thumbprint, or the cache files could not be produced.
    /// </summary>
    internal static async Task<(string? certificateFilePath, string? keyFilePath, string? thumbprint)> GetCachedCertificateFilePathsAsync(
        X509Certificate2 certificate,
        string? password,
        CancellationToken cancellationToken)
    {
        if (!certificate.IsAspNetCoreDevelopmentCertificate() || string.IsNullOrWhiteSpace(certificate.Thumbprint))
        {
            return (null, null, null);
        }
 
        string certificateFileName;
        string keyFileName;
 
        await s_certificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            var cached = EnsureCachedKeyMaterial(certificate, password);
            certificateFileName = cached.certFileName;
            keyFileName = cached.keyFileName;
        }
        finally
        {
            s_certificateCacheSemaphore.Release();
        }
 
        return File.Exists(certificateFileName) && File.Exists(keyFileName)
            ? (certificateFileName, keyFileName, certificate.Thumbprint)
            : (null, null, null);
    }
 
    /// <summary>
    /// Ensures the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files
    /// exist for the specified certificate, returning their paths along with the cached PFX/key
    /// byte contents. On cache miss the private key is accessed once (which may trigger a keychain
    /// prompt on macOS) to export both private-key formats; on cache hit the bytes are read
    /// directly from disk to avoid importing the PFX into the macOS keychain via
    /// <see cref="X509Certificate2"/>. The public .crt file is written whenever it is missing,
    /// since it can be produced without accessing the private key.
    /// </summary>
    /// <remarks>The caller must hold <see cref="s_certificateCacheSemaphore"/>.</remarks>
    private static (string certFileName, string pfxFileName, string keyFileName, byte[] pfxBytes, byte[] keyBytes) EnsureCachedKeyMaterial(
        X509Certificate2 certificate, string? password)
    {
        var lookup = GetKeyMaterialCacheLookup(certificate, password);
        var certFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.crt");
        var pfxFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.pfx");
        var keyFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.key");
 
        var cachedPfx = TryReadCacheFile(pfxFileName);
        var cachedKey = TryReadCacheFile(keyFileName);
 
        byte[] pfxBytes;
        byte[] keyBytes;
 
        if (cachedPfx is not null && cachedKey is not null)
        {
            pfxBytes = cachedPfx;
            keyBytes = cachedKey;
        }
        else
        {
            // Fall back to accessing the private key directly (triggers a keychain prompt on macOS).
            // Always produce both formats for caching, even if the caller only needs one.
            var result = ExportFromPrivateKey(certificate, password, needKeyPem: true, needPfx: true);
            pfxBytes = result.pfxBytes!;
            keyBytes = Encoding.UTF8.GetBytes(result.keyPem!);
            Array.Clear(result.keyPem!);
 
            WriteCacheFiles(certFileName, certificate, pfxFileName, pfxBytes, keyFileName, keyBytes);
        }
 
        // The public certificate cache file can be produced without touching the private key,
        // so refresh it whenever it is missing (including on cache hits from older caches that
        // pre-date this file).
        if (!File.Exists(certFileName))
        {
            WriteCacheFiles(certFileName, certificate, pfxFileName: null, pfxBytes: null, keyFileName: null, keyBytes: null);
        }
 
        return (certFileName, pfxFileName, keyFileName, pfxBytes, keyBytes);
    }
 
    private static string GetKeyMaterialCacheLookup(X509Certificate2 certificate, string? password)
    {
        var lookup = certificate.Thumbprint;
        if (password is not null)
        {
            lookup += $"-{password}";
        }
 
        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(lookup)));
    }
 
    /// <summary>
    /// Reads a cache file from disk, returning null if it doesn't exist or is empty.
    /// </summary>
    private static byte[]? TryReadCacheFile(string path)
    {
        try
        {
            if (!File.Exists(path))
            {
                return null;
            }
 
            var bytes = File.ReadAllBytes(path);
            return bytes.Length > 0 ? bytes : null;
        }
        catch
        {
            return null;
        }
    }
 
    /// <summary>
    /// Exports PEM key and/or PFX from the certificate using a single private key access.
    /// </summary>
    private static (char[]? keyPem, byte[]? pfxBytes) ExportFromPrivateKey(
        X509Certificate2 certificate, string? password, bool needKeyPem, bool needPfx)
    {
        if (!needKeyPem && !needPfx)
        {
            return (null, null);
        }
 
        using AsymmetricAlgorithm? privateKey =
            (AsymmetricAlgorithm?)certificate.GetRSAPrivateKey()
            ?? certificate.GetECDsaPrivateKey();
 
        if (privateKey is null)
        {
            throw new InvalidOperationException("The certificate does not have an associated RSA or ECDSA private key.");
        }
 
        var keyPem = needKeyPem ? ExportKeyPem(privateKey, password) : null;
        var pfxBytes = needPfx ? certificate.Export(X509ContentType.Pfx, password) : null;
 
        return (keyPem, pfxBytes);
    }
 
    /// <summary>
    /// Exports an asymmetric private key in PEM format. Supports RSA and ECDSA keys.
    /// </summary>
    private static char[] ExportKeyPem(AsymmetricAlgorithm privateKey, string? password)
    {
        var keyBytes = privateKey.ExportEncryptedPkcs8PrivateKey(
            password ?? string.Empty,
            new PbeParameters(
                PbeEncryptionAlgorithm.Aes256Cbc,
                HashAlgorithmName.SHA256,
                iterationCount: password is null ? 1 : 100_000));
        var pemKey = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes);
 
        if (password is null)
        {
            using AsymmetricAlgorithm tempKey = privateKey switch
            {
                RSA => RSA.Create(),
                ECDsa => ECDsa.Create(),
                _ => throw new InvalidOperationException($"Unsupported private key type: {privateKey.GetType().FullName}.")
            };
            tempKey.ImportFromEncryptedPem(pemKey, string.Empty);
            Array.Clear(keyBytes, 0, keyBytes.Length);
            Array.Clear(pemKey, 0, pemKey.Length);
            keyBytes = tempKey.ExportPkcs8PrivateKey();
            pemKey = PemEncoding.Write("PRIVATE KEY", keyBytes);
        }
 
        Array.Clear(keyBytes, 0, keyBytes.Length);
        return pemKey;
    }
 
    /// <summary>
    /// Writes the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files.
    /// Any of the file-name / payload pairs may be null to skip writing that file. Best-effort;
    /// failures are silently ignored.
    /// </summary>
    private static void WriteCacheFiles(
        string certFileName,
        X509Certificate2 certificate,
        string? pfxFileName,
        byte[]? pfxBytes,
        string? keyFileName,
        byte[]? keyBytes)
    {
        try
        {
            DirectoryHelper.CreateWithOwnerOnlyPermissions(s_userDevCertificateLocation);
 
            File.WriteAllText(certFileName, certificate.ExportCertificatePem());
 
            if (pfxFileName is not null && pfxBytes is not null)
            {
                File.WriteAllBytes(pfxFileName, pfxBytes);
            }
 
            if (keyFileName is not null && keyBytes is not null)
            {
                File.WriteAllBytes(keyFileName, keyBytes);
            }
        }
        catch
        {
            // Best-effort caching operation.
        }
    }
}