From d06fc0f0ec985ed4a3cc6135647d5498b2d1800a Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 21 Nov 2025 15:09:50 -0800 Subject: [PATCH 01/10] Expose runtime certificate properties in WithContainerFileSystem callback context --- .../ContainerFileSystemCallbackAnnotation.cs | 31 +++++++++++++ src/Aspire.Hosting/Dcp/DcpExecutor.cs | 45 ++++++++++++++++--- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs index d86895d58be..7b54d21ecb6 100644 --- a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; namespace Aspire.Hosting.ApplicationModel; @@ -276,4 +277,34 @@ public sealed class ContainerFileSystemCallbackContext /// The app model resource the callback is associated with. /// public required IResource Model { get; init; } + + /// + /// Indicates whether the resource has a server authentication certificate configured. + /// + [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public bool HasServerAuthenticationCertificate { get; set; } + + /// + /// The path to the server authentication certificate file inside the container. + /// + [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public string? ServerAuthenticationCertificatePath { get; set; } + + /// + /// The path to the server authentication key file inside the container. + /// + [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public string? ServerAuthenticationKeyPath { get; set; } + + /// + /// The path to the server authentication PFX file inside the container. + /// + [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public string? ServerAuthenticationCertificatePfxPath { get; set; } + + /// + /// The password for the server authentication PFX file inside the container. + /// + [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public string? ServerAuthenticationCertificatePassword { get; set; } } \ No newline at end of file diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 9c233dac097..26028e584c2 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1898,8 +1898,26 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour spec.PemCertificates = pemCertificates; + var buildCreateFilesContext = new BuildCreateFilesContext + { + Resource = modelContainerResource, + CertificateTrustScope = configContext.CertificateTrustScope, + CertificateTrustBundlePath = $"{certificatesDestination}/cert.pem", + HasServerAuthenticationCertificate = configContext.ServerAuthCertificate is not null, + }; + + if (buildCreateFilesContext.HasServerAuthenticationCertificate) + { + buildCreateFilesContext.ServerAuthenticationCertificatePath = $"{serverAuthCertificatesBasePath}/{configContext.ServerAuthCertificate?.Thumbprint}.crt"; + buildCreateFilesContext.ServerAuthenticationKeyPath = $"{serverAuthCertificatesBasePath}/{configContext.ServerAuthCertificate?.Thumbprint}.key"; + buildCreateFilesContext.ServerAuthenticationCertificatePfxPath = $"{serverAuthCertificatesBasePath}/{configContext.ServerAuthCertificate?.Thumbprint}.pfx"; + buildCreateFilesContext.ServerAuthenticationCertificatePassword = configContext.ServerAuthPassword; + } + // Build files that need to be created inside the container - var createFiles = await BuildCreateFilesAsync(modelContainerResource, cancellationToken).ConfigureAwait(false); + var createFiles = await BuildCreateFilesAsync( + buildCreateFilesContext, + cancellationToken).ConfigureAwait(false); if (configContext.ServerAuthCertificate is not null) { @@ -2347,19 +2365,36 @@ async Task EnsureResourceDeletedAsync(string resourceName) where T : CustomRe } } - private async Task> BuildCreateFilesAsync(IResource modelResource, CancellationToken cancellationToken) + private class BuildCreateFilesContext + { + public required IResource Resource { get; init; } + public CertificateTrustScope CertificateTrustScope { get; init; } + public string? CertificateTrustBundlePath { get; set; } + public string? CertificateTrustDirectoriesPath { get; set; } + public bool HasServerAuthenticationCertificate { get; init; } + public string? ServerAuthenticationCertificatePath { get; set; } + public string? ServerAuthenticationKeyPath { get; set; } + public string? ServerAuthenticationCertificatePfxPath { get; set; } + public string? ServerAuthenticationCertificatePassword { get; set; } + } + + private async Task> BuildCreateFilesAsync(BuildCreateFilesContext context, CancellationToken cancellationToken) { var createFiles = new List(); - if (modelResource.TryGetAnnotationsOfType(out var createFileAnnotations)) + if (context.Resource.TryGetAnnotationsOfType(out var createFileAnnotations)) { foreach (var a in createFileAnnotations) { var entries = await a.Callback( new() { - Model = modelResource, - ServiceProvider = _executionContext.ServiceProvider + Model = context.Resource, + ServiceProvider = _executionContext.ServiceProvider, + ServerAuthenticationCertificatePath = context.ServerAuthenticationCertificatePath, + ServerAuthenticationKeyPath = context.ServerAuthenticationKeyPath, + ServerAuthenticationCertificatePfxPath = context.ServerAuthenticationCertificatePfxPath, + ServerAuthenticationCertificatePassword = context.ServerAuthenticationCertificatePassword, }, cancellationToken).ConfigureAwait(false); From 64a42195f80f25762cb984a4e5f6d5cfcddcdc10 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sun, 23 Nov 2025 14:46:04 -0800 Subject: [PATCH 02/10] Support using WithContainerFiles for server authentication cert config, avoid excessive keychain access prompts on Mac --- .../KeycloakResourceBuilderExtensions.cs | 4 +- .../PythonAppResourceBuilderExtensions.cs | 4 +- .../RedisBuilderExtensions.cs | 8 +- .../YarpResourceExtensions.cs | 4 +- .../ContainerFileSystemCallbackAnnotation.cs | 23 +- .../ApplicationModel/ReferenceExpression.cs | 7 + .../ApplicationModel/ResourceExtensions.cs | 62 ++-- .../X509CertificateResource.cs | 21 ++ src/Aspire.Hosting/Dcp/DcpExecutor.cs | 278 +++++++++++++----- .../DeveloperCertificateService.cs | 8 +- .../DistributedApplicationOptions.cs | 10 +- .../IDeveloperCertificateService.cs | 6 +- src/Shared/KnownConfigNames.cs | 1 + 13 files changed, 312 insertions(+), 124 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/X509CertificateResource.cs diff --git a/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs index 5eeba5df622..cddb542827a 100644 --- a/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs @@ -113,12 +113,12 @@ public static IResourceBuilder AddKeycloak( bool addHttps = false; if (!resource.TryGetLastAnnotation(out var annotation)) { - if (developerCertificateService.DefaultTlsTerminationEnabled) + if (developerCertificateService.UseForServerAuthentication) { addHttps = true; } } - else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.DefaultTlsTerminationEnabled) || annotation.Certificate is not null) + else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.UseForServerAuthentication) || annotation.Certificate is not null) { addHttps = true; } diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 1054ed58a6c..66900ffafd5 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -320,14 +320,14 @@ public static IResourceBuilder AddUvicornApp( bool addHttps = false; if (!resourceBuilder.Resource.TryGetLastAnnotation(out var annotation)) { - if (developerCertificateService.DefaultTlsTerminationEnabled) + if (developerCertificateService.UseForServerAuthentication) { // If no certificate is configured, and the developer certificate service supports container trust, // configure the resource to use the developer certificate for its key pair. addHttps = true; } } - else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.DefaultTlsTerminationEnabled) || annotation.Certificate is not null) + else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.UseForServerAuthentication) || annotation.Certificate is not null) { addHttps = true; } diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index bebc4cf10d9..76f543aa33c 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -180,12 +180,12 @@ public static IResourceBuilder AddRedis( bool addHttps = false; if (!redis.TryGetLastAnnotation(out var annotation)) { - if (developerCertificateService.DefaultTlsTerminationEnabled) + if (developerCertificateService.UseForServerAuthentication) { addHttps = true; } } - else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.DefaultTlsTerminationEnabled) || annotation.Certificate is not null) + else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.UseForServerAuthentication) || annotation.Certificate is not null) { addHttps = true; } @@ -390,12 +390,12 @@ public static IResourceBuilder WithRedisInsight(this IResourceBui bool addHttps = false; if (!resource.TryGetLastAnnotation(out var annotation)) { - if (developerCertificateService.DefaultTlsTerminationEnabled) + if (developerCertificateService.UseForServerAuthentication) { addHttps = true; } } - else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.DefaultTlsTerminationEnabled) || annotation.Certificate is not null) + else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.UseForServerAuthentication) || annotation.Certificate is not null) { addHttps = true; } diff --git a/src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs b/src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs index 44540966d77..cf45b2c245d 100644 --- a/src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs +++ b/src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs @@ -62,13 +62,13 @@ public static IResourceBuilder AddYarp( bool addHttps = false; if (!resource.TryGetLastAnnotation(out var annotation)) { - if (developerCertificateService.DefaultTlsTerminationEnabled) + if (developerCertificateService.UseForServerAuthentication) { // If no specific certificate is configured addHttps = true; } } - else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.DefaultTlsTerminationEnabled) || annotation.Certificate is not null) + else if (annotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.UseForServerAuthentication) || annotation.Certificate is not null) { addHttps = true; } diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs index 7b54d21ecb6..ddee6c94f6c 100644 --- a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs @@ -279,32 +279,35 @@ public sealed class ContainerFileSystemCallbackContext public required IResource Model { get; init; } /// - /// Indicates whether the resource has a server authentication certificate configured. + /// The path to the server authentication certificate file inside the container. /// [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - public bool HasServerAuthenticationCertificate { get; set; } + public ContainerFileSystemCallbackServerAuthenticationCertificateContext? ServerAuthenticationCertificateContext { get; set; } +} +/// +/// Represents the context for server authentication certificate files in a . +/// +[Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ContainerFileSystemCallbackServerAuthenticationCertificateContext +{ /// /// The path to the server authentication certificate file inside the container. /// - [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - public string? ServerAuthenticationCertificatePath { get; set; } + public ReferenceExpression CertificatePath { get; init; } = null!; /// /// The path to the server authentication key file inside the container. /// - [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - public string? ServerAuthenticationKeyPath { get; set; } + public ReferenceExpression KeyPath { get; init; } = null!; /// /// The path to the server authentication PFX file inside the container. /// - [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - public string? ServerAuthenticationCertificatePfxPath { get; set; } + public ReferenceExpression PfxPath { get; init; } = null!; /// /// The password for the server authentication PFX file inside the container. /// - [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - public string? ServerAuthenticationCertificatePassword { get; set; } + public string? Password { get; init; } } \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index 52e30e32c2a..df7ab6552d0 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -63,6 +63,11 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri public string ValueExpression => string.Format(CultureInfo.InvariantCulture, Format, _manifestExpressions); + /// + /// Indicates whether this expression was ever referenced to get its value. + /// + internal bool WasReferenced { get; private set; } + /// /// Gets the value of the expression. The final string value after evaluating the format string and its parameters. /// @@ -70,6 +75,8 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri /// A . public async ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken) { + WasReferenced = true; + // NOTE: any logical changes to this method should also be made to ExpressionResolver.EvalExpressionAsync if (Format.Length == 0) { diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 666f483ac69..038a21093b7 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -485,12 +485,7 @@ internal class ResourceConfigurationContext /// /// The server authentication certificate for the resource, if any. /// - public X509Certificate2? ServerAuthCertificate { get; init; } - - /// - /// The password for the server authentication certificate, if any. - /// - public string? ServerAuthPassword { get; init; } + public ServerAuthenticationCertificateConfigurationDetails? ServerAuthenticationCertificateConfiguration { get; init; } /// /// Any exception that occurred during the configuration processing. @@ -547,11 +542,10 @@ internal static async ValueTask ProcessConfigurati cancellationToken).ConfigureAwait(false); } - X509Certificate2? serverAuthCertificate = null; - string? serverAuthPassword = null; + ServerAuthenticationCertificateConfigurationDetails? serverAuthCertificateConfiguration = null; if (withServerAuthCertificateConfig) { - (args, envVars, serverAuthCertificate, serverAuthPassword) = await resource.GatherServerAuthCertificateConfigAsync( + (args, envVars, serverAuthCertificateConfiguration) = await resource.GatherServerAuthCertificateConfigAsync( executionContext, args, envVars, @@ -616,8 +610,7 @@ await ProcessGatheredEnvironmentVariableValuesAsync( EnvironmentVariables = resolvedEnvVars, CertificateTrustScope = certificateTrustScope, TrustedCertificates = trustedCertificates!, - ServerAuthCertificate = serverAuthCertificate, - ServerAuthPassword = serverAuthPassword, + ServerAuthenticationCertificateConfiguration = serverAuthCertificateConfiguration, Exception = exception, }; } @@ -751,6 +744,32 @@ internal class ServerAuthCertificateConfigBuilderContext public required ReferenceExpression PfxPath { get; init; } } + /// + /// Holds the details of server authentication certificate configuration. + /// + internal sealed class ServerAuthenticationCertificateConfigurationDetails + { + /// + /// The server authentication certificate for the resource, if any. + /// + public required X509Certificate2 Certificate { get; init; } + + /// + /// Indicates whether the resource references a PEM key for server authentication. + /// + public required bool ReferencesPemKey { get; set; } + + /// + /// Indicates whether the resource references a PFX file for server authentication. + /// + public required bool ReferencesPfx { get; set; } + + /// + /// The passphrase for the server authentication certificate, if any. + /// + public string? Password { get; init; } + } + /// /// Gathers server authentication certificate configuration for the specified resource within the given execution context. /// @@ -760,8 +779,8 @@ internal class ServerAuthCertificateConfigBuilderContext /// Existing environment variables that will be used to initialize the context for the config callback. /// A factory function to create the context for building server authentication certificate configuration; provides the paths for the certificate, key, and PFX files. /// A token to monitor for cancellation requests. - /// The resulting command line arguments, environment variables, and optionally the certificate and passphrase for server auth. - internal static async ValueTask<(List arguments, Dictionary environmentVariables, X509Certificate2? serverAuthCertificate, string? passphrase)> GatherServerAuthCertificateConfigAsync( + /// The resulting command line arguments, environment variables, and optionally the specific server authentication certificate configuration details. + internal static async ValueTask<(List arguments, Dictionary environmentVariables, ServerAuthenticationCertificateConfigurationDetails? details)> GatherServerAuthCertificateConfigAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, List arguments, @@ -778,14 +797,14 @@ internal class ServerAuthCertificateConfigBuilderContext if (effectiveAnnotation is null) { // Should never happen - return (arguments, environmentVariables, null, null); + return (arguments, environmentVariables, null); } X509Certificate2? certificate = effectiveAnnotation.Certificate; if (certificate is null) { var developerCertificateService = executionContext.ServiceProvider.GetRequiredService(); - if (effectiveAnnotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.DefaultTlsTerminationEnabled)) + if (effectiveAnnotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.UseForServerAuthentication)) { certificate = developerCertificateService.Certificates.FirstOrDefault(); } @@ -794,7 +813,7 @@ internal class ServerAuthCertificateConfigBuilderContext if (certificate is null) { // No certificate to configure, do nothing - return (arguments, environmentVariables, null, null); + return (arguments, environmentVariables, null); } var configBuilderContext = certificateConfigContextFactory(certificate); @@ -819,7 +838,16 @@ internal class ServerAuthCertificateConfigBuilderContext string? password = effectiveAnnotation.Password is not null ? await effectiveAnnotation.Password.GetValueAsync(cancellationToken).ConfigureAwait(false) : null; - return (arguments, environmentVariables, certificate, password); + return ( + arguments, + environmentVariables, + new ServerAuthenticationCertificateConfigurationDetails() + { + Certificate = certificate, + Password = password, + ReferencesPemKey = context.KeyPath.WasReferenced, + ReferencesPfx = context.PfxPath.WasReferenced, + }); } internal static async ValueTask ResolveValueAsync( diff --git a/src/Aspire.Hosting/ApplicationModel/X509CertificateResource.cs b/src/Aspire.Hosting/ApplicationModel/X509CertificateResource.cs new file mode 100644 index 00000000000..9d113dad08c --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/X509CertificateResource.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +/// +/// Represents a X509 Certificate resource. This may be backed by a local certificate in run mode or a remote certificate in deploy mode. +/// +[Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class X509CertificateResource : Resource +{ + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The name of the resource. + public X509CertificateResource(string name) : base(name) + { + ArgumentNullException.ThrowIfNull(name); + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 26028e584c2..9caece0f44e 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -55,6 +55,9 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I // it probably means DCP crashed and there is no point trying further. private static readonly TimeSpan s_disposeTimeout = TimeSpan.FromSeconds(10); + // Well-known location on disk where dev-certs are stored. + private static readonly string s_macOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https"); + // Regex for normalizing application names. [GeneratedRegex("""^(?.+?)\.?AppHost$""", RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant)] private static partial Regex ApplicationNameRegex(); @@ -78,6 +81,9 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I private readonly IDeveloperCertificateService _developerCertificateService; private readonly DcpResourceState _resourceState; private readonly ResourceSnapshotBuilder _snapshotBuilder; + private readonly ConcurrentDictionary _serverAuthenticationPemKeyCache = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _serverAuthenticationPfxCache = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _serverCertificateCacheSemaphore = new(1, 1); private readonly string _normalizedApplicationName; @@ -283,6 +289,9 @@ public async ValueTask DisposeAsync() { var disposeCts = new CancellationTokenSource(); disposeCts.CancelAfter(s_disposeTimeout); + _serverCertificateCacheSemaphore.Dispose(); + _serverAuthenticationPemKeyCache.Clear(); + _serverAuthenticationPfxCache.Clear(); await StopAsync(disposeCts.Token).ConfigureAwait(false); } @@ -1559,23 +1568,39 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou }, cancellationToken).ConfigureAwait(false); - if (configContext.ServerAuthCertificate is not null) + if (configContext.ServerAuthenticationCertificateConfiguration is not null) { - (var certificatePem, var keyPem) = GetCertificateKeyPair(configContext.ServerAuthCertificate, configContext.ServerAuthPassword); - var pfxBytes = configContext.ServerAuthCertificate.Export(X509ContentType.Pfx, configContext.ServerAuthPassword); + var thumbprint = configContext.ServerAuthenticationCertificateConfiguration.Certificate.Thumbprint; + var publicCetificatePem = configContext.ServerAuthenticationCertificateConfiguration.Certificate.ExportCertificatePem(); + (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(configContext.ServerAuthenticationCertificateConfiguration, cancellationToken).ConfigureAwait(false); - var certificateBytes = Encoding.ASCII.GetBytes(certificatePem); - var keyBytes = Encoding.ASCII.GetBytes(keyPem); + if (OperatingSystem.IsWindows()) + { + Directory.CreateDirectory(baseServerAuthOutputPath); + } + else + { + Directory.CreateDirectory(baseServerAuthOutputPath, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); + } - Directory.CreateDirectory(baseServerAuthOutputPath); + File.WriteAllText(Path.Join(baseServerAuthOutputPath, $"{thumbprint}.crt"), publicCetificatePem); - // Write each of the certificate, key, and PFX assets to the temp folder - File.WriteAllBytes(Path.Join(baseServerAuthOutputPath, $"{configContext.ServerAuthCertificate.Thumbprint}.key"), keyBytes); - File.WriteAllBytes(Path.Join(baseServerAuthOutputPath, $"{configContext.ServerAuthCertificate.Thumbprint}.crt"), certificateBytes); - File.WriteAllBytes(Path.Join(baseServerAuthOutputPath, $"{configContext.ServerAuthCertificate.Thumbprint}.pfx"), pfxBytes); + if (keyPem is not null) + { + var keyBytes = Encoding.ASCII.GetBytes(keyPem); + + // Write each of the certificate, key, and PFX assets to the temp folder + File.WriteAllBytes(Path.Join(baseServerAuthOutputPath, $"{thumbprint}.key"), keyBytes); - Array.Clear(keyPem, 0, keyPem.Length); - Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(keyPem, 0, keyPem.Length); + Array.Clear(keyBytes, 0, keyBytes.Length); + } + + if (pfxBytes is not null) + { + File.WriteAllBytes(Path.Join(baseServerAuthOutputPath, $"{thumbprint}.pfx"), pfxBytes); + Array.Clear(pfxBytes, 0, pfxBytes.Length); + } } // Add the certificates to the executable spec so they'll be placed in the DCP config @@ -1903,15 +1928,18 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour Resource = modelContainerResource, CertificateTrustScope = configContext.CertificateTrustScope, CertificateTrustBundlePath = $"{certificatesDestination}/cert.pem", - HasServerAuthenticationCertificate = configContext.ServerAuthCertificate is not null, }; - if (buildCreateFilesContext.HasServerAuthenticationCertificate) + if (configContext.ServerAuthenticationCertificateConfiguration is not null) { - buildCreateFilesContext.ServerAuthenticationCertificatePath = $"{serverAuthCertificatesBasePath}/{configContext.ServerAuthCertificate?.Thumbprint}.crt"; - buildCreateFilesContext.ServerAuthenticationKeyPath = $"{serverAuthCertificatesBasePath}/{configContext.ServerAuthCertificate?.Thumbprint}.key"; - buildCreateFilesContext.ServerAuthenticationCertificatePfxPath = $"{serverAuthCertificatesBasePath}/{configContext.ServerAuthCertificate?.Thumbprint}.pfx"; - buildCreateFilesContext.ServerAuthenticationCertificatePassword = configContext.ServerAuthPassword; + var thumbprint = configContext.ServerAuthenticationCertificateConfiguration.Certificate.Thumbprint; + buildCreateFilesContext.ServerAuthenticationCertificateContext = new ContainerFileSystemCallbackServerAuthenticationCertificateContext + { + CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.crt"), + KeyPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.key"), + PfxPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.pfx"), + Password = configContext.ServerAuthenticationCertificateConfiguration.Password, + }; } // Build files that need to be created inside the container @@ -1919,10 +1947,28 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour buildCreateFilesContext, cancellationToken).ConfigureAwait(false); - if (configContext.ServerAuthCertificate is not null) + if (configContext.ServerAuthenticationCertificateConfiguration is not null) + { + // Determine if the pfx is required + configContext.ServerAuthenticationCertificateConfiguration.ReferencesPemKey = configContext.ServerAuthenticationCertificateConfiguration.ReferencesPemKey || buildCreateFilesContext.ServerAuthenticationCertificateContext?.KeyPath?.WasReferenced == true; + configContext.ServerAuthenticationCertificateConfiguration.ReferencesPfx = configContext.ServerAuthenticationCertificateConfiguration.ReferencesPfx || buildCreateFilesContext.ServerAuthenticationCertificateContext?.PfxPath?.WasReferenced == true; + } + + if (configContext.ServerAuthenticationCertificateConfiguration is not null) { - (var certificatePem, var keyPem) = GetCertificateKeyPair(configContext.ServerAuthCertificate, configContext.ServerAuthPassword); - var pfxBytes = configContext.ServerAuthCertificate.Export(X509ContentType.Pfx, configContext.ServerAuthPassword); + var thumbprint = configContext.ServerAuthenticationCertificateConfiguration.Certificate.Thumbprint; + var publicCertificatePem = configContext.ServerAuthenticationCertificateConfiguration.Certificate.ExportCertificatePem(); + (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(configContext.ServerAuthenticationCertificateConfiguration, cancellationToken).ConfigureAwait(false); + + var certificateFiles = new List() + { + new ContainerFileSystemEntry + { + Name = thumbprint + ".crt", + Type = ContainerFileSystemEntryType.File, + Contents = new string(publicCertificatePem), + } + }; var baseOutputPath = Path.Join(_locations.DcpSessionDir, dcpContainerResource.Name(), "private"); if (spec.Persistent == true) @@ -1931,40 +1977,48 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour baseOutputPath = Path.Join(Path.GetTempPath(), "aspire", _configuration["AppHost:Sha256"], spec.ContainerName, "private"); } - // The PFX file is binary, so we need to write it to a temp file first - var pfxOutputPath = Path.Join(baseOutputPath, $"{configContext.ServerAuthCertificate.Thumbprint}.pfx"); - Directory.CreateDirectory(baseOutputPath); - File.WriteAllBytes(pfxOutputPath, pfxBytes); + if (keyPem is not null) + { + certificateFiles.Add(new ContainerFileSystemEntry + { + Name = thumbprint + ".key", + Type = ContainerFileSystemEntryType.File, + Contents = new string(keyPem), + }); + + Array.Clear(keyPem, 0, keyPem.Length); + } + + if (pfxBytes is not null) + { + // The PFX file is binary, so we need to write it to a temp file first + var pfxOutputPath = Path.Join(baseOutputPath, $"{thumbprint}.pfx"); + if (OperatingSystem.IsWindows()) + { + Directory.CreateDirectory(baseOutputPath); + } + else + { + Directory.CreateDirectory(baseOutputPath, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); + } + File.WriteAllBytes(pfxOutputPath, pfxBytes); + + certificateFiles.Add(new ContainerFileSystemEntry + { + Name = thumbprint + ".pfx", + Type = ContainerFileSystemEntryType.File, + Source = pfxOutputPath, + }); + + Array.Clear(pfxBytes, 0, pfxBytes.Length); + } // Write the certificate and key to the container filesystem createFiles.Add(new ContainerCreateFileSystem { Destination = serverAuthCertificatesBasePath, - Entries = [ - new ContainerFileSystemEntry - { - Name = configContext.ServerAuthCertificate.Thumbprint + ".key", - Type = ContainerFileSystemEntryType.File, - Contents = new string(keyPem), - }, - new ContainerFileSystemEntry - { - Name = configContext.ServerAuthCertificate.Thumbprint + ".crt", - Type = ContainerFileSystemEntryType.File, - Contents = new string(certificatePem), - }, - // Copy the PFX file from the temp location - new ContainerFileSystemEntry - { - Name = configContext.ServerAuthCertificate.Thumbprint + ".pfx", - Type = ContainerFileSystemEntryType.File, - Source = pfxOutputPath, - }, - ], + Entries = certificateFiles, }); - - Array.Clear(keyPem, 0, keyPem.Length); - Array.Clear(pfxBytes, 0, pfxBytes.Length); } // Set the final args, env vars, and create files on the container spec @@ -2371,11 +2425,7 @@ private class BuildCreateFilesContext public CertificateTrustScope CertificateTrustScope { get; init; } public string? CertificateTrustBundlePath { get; set; } public string? CertificateTrustDirectoriesPath { get; set; } - public bool HasServerAuthenticationCertificate { get; init; } - public string? ServerAuthenticationCertificatePath { get; set; } - public string? ServerAuthenticationKeyPath { get; set; } - public string? ServerAuthenticationCertificatePfxPath { get; set; } - public string? ServerAuthenticationCertificatePassword { get; set; } + public ContainerFileSystemCallbackServerAuthenticationCertificateContext? ServerAuthenticationCertificateContext { get; set; } } private async Task> BuildCreateFilesAsync(BuildCreateFilesContext context, CancellationToken cancellationToken) @@ -2391,10 +2441,7 @@ private async Task> BuildCreateFilesAsync(BuildC { Model = context.Resource, ServiceProvider = _executionContext.ServiceProvider, - ServerAuthenticationCertificatePath = context.ServerAuthenticationCertificatePath, - ServerAuthenticationKeyPath = context.ServerAuthenticationKeyPath, - ServerAuthenticationCertificatePfxPath = context.ServerAuthenticationCertificatePfxPath, - ServerAuthenticationCertificatePassword = context.ServerAuthenticationCertificatePassword, + ServerAuthenticationCertificateContext = context.ServerAuthenticationCertificateContext, }, cancellationToken).ConfigureAwait(false); @@ -2438,38 +2485,107 @@ await modelResource.ProcessContainerRuntimeArgValues( return (runArgs, failedToApplyArgs); } - private static (char[] certificatePem, char[] keyPem) GetCertificateKeyPair(X509Certificate2 certificate, string? passphrase) + /// + /// Returns the certificate PEM format key and/or PFX bytes based on the provided configuration. + /// Only the formats referenced in resource configuration will be returned. + /// + /// The configuration details. + /// A token that can be used to cancel the operation. + /// A tuple containing the PEM-encoded key and PFX bytes, if appropriate for the configuration. + /// + private async Task<(char[]? keyPem, byte[]? pfxBytes)> GetCertificateKeyMaterialAsync(ResourceExtensions.ServerAuthenticationCertificateConfigurationDetails configuration, CancellationToken cancellationToken) { - // See: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/CertificateGeneration/CertificateManager.cs - using var privateKey = certificate.GetRSAPrivateKey(); - if (privateKey is null) + var certificate = configuration.Certificate; + var lookup = certificate.Thumbprint; + if (configuration.Password is not null) { - throw new InvalidOperationException("The certificate does not have an associated RSA private key."); + lookup += $"-{configuration.Password}"; } - var keyBytes = privateKey.ExportEncryptedPkcs8PrivateKey( - passphrase ?? string.Empty, - new PbeParameters( - PbeEncryptionAlgorithm.Aes256Cbc, - HashAlgorithmName.SHA256, - iterationCount: passphrase is null ? 1 : 100_000)); - var pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + lookup = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(lookup))); - if (passphrase is null) + char[]? pemKey = null; + byte[]? pfxBytes = null; + // 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 _serverCertificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - using var tempKey = RSA.Create(); - tempKey.ImportFromEncryptedPem(pem, string.Empty); - Array.Clear(keyBytes, 0, keyBytes.Length); - Array.Clear(pem, 0, pem.Length); - keyBytes = tempKey.ExportPkcs8PrivateKey(); - pem = PemEncoding.Write("PRIVATE KEY", keyBytes); - } + if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) + { + // On macOS the ASP.NET Core dev cert is cached on disk to avoid excessive prompting for access to the keychain. + // Check to see if the current developer certificate exists in the cache and use that copy instead if available. + if (Directory.Exists(s_macOSUserHttpsCertificateLocation)) + { + var candidateCachedCertificate = Path.Combine(s_macOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx"); + if (File.Exists(candidateCachedCertificate)) + { + try + { + var cachedCertificate = new X509Certificate2(candidateCachedCertificate); - var contents = PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert)); + if (cachedCertificate.IsAspNetCoreDevelopmentCertificate() && cachedCertificate.Thumbprint.Equals(certificate.Thumbprint, StringComparison.Ordinal)) + { + certificate = cachedCertificate; + } + } + catch + { + // Ignore exceptions and continue with the original certificate + } + } + } + } - Array.Clear(keyBytes, 0, keyBytes.Length); + if (configuration.ReferencesPemKey) + { + pemKey = _serverAuthenticationPemKeyCache.GetOrAdd(lookup, _ => + { + // See: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/CertificateGeneration/CertificateManager.cs + using var privateKey = certificate.GetRSAPrivateKey(); + if (privateKey is null) + { + throw new InvalidOperationException("The certificate does not have an associated RSA private key."); + } + + var keyBytes = privateKey.ExportEncryptedPkcs8PrivateKey( + configuration.Password ?? string.Empty, + new PbeParameters( + PbeEncryptionAlgorithm.Aes256Cbc, + HashAlgorithmName.SHA256, + iterationCount: configuration.Password is null ? 1 : 100_000)); + var pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + + if (configuration.Password is null) + { + using var tempKey = RSA.Create(); + tempKey.ImportFromEncryptedPem(pem, string.Empty); + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + keyBytes = tempKey.ExportPkcs8PrivateKey(); + pem = PemEncoding.Write("PRIVATE KEY", keyBytes); + } + + Array.Clear(keyBytes, 0, keyBytes.Length); + + return pem; + }); + } + + if (configuration.ReferencesPfx) + { + pfxBytes = _serverAuthenticationPfxCache.GetOrAdd(lookup, _ => + { + return certificate.Export(X509ContentType.Pfx, configuration.Password); + }); + } + } + finally + { + _serverCertificateCacheSemaphore.Release(); + } - return (contents, pem); + return (pemKey, pfxBytes); } private static List BuildContainerPorts(RenderedModelResource cr) diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index 6163829cf61..c2e49967eec 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -75,6 +75,11 @@ public DeveloperCertificateService(ILogger logger, TrustCertificate = configuration.GetBool(KnownConfigNames.TrustDeveloperCertificate) ?? options.TrustDeveloperCertificate ?? true; + + // By default, only use for server authentication if trust is also enabled (and a developer certificate with a private key is available) + UseForServerAuthentication = configuration.GetBool(KnownConfigNames.UseDeveloperCertificateForServerAuthentication) ?? + options.UseDeveloperCertificateForServerAuthentication ?? + true && TrustCertificate && _supportsTlsTermination.Value; } /// @@ -86,5 +91,6 @@ public DeveloperCertificateService(ILogger logger, /// public bool TrustCertificate { get; } - public bool DefaultTlsTerminationEnabled => !OperatingSystem.IsMacOS() && _supportsTlsTermination.Value && TrustCertificate; + /// + public bool UseForServerAuthentication { get; } } diff --git a/src/Aspire.Hosting/DistributedApplicationOptions.cs b/src/Aspire.Hosting/DistributedApplicationOptions.cs index e1241b87092..c9e8e6a120d 100644 --- a/src/Aspire.Hosting/DistributedApplicationOptions.cs +++ b/src/Aspire.Hosting/DistributedApplicationOptions.cs @@ -100,11 +100,17 @@ public string? DashboardApplicationName public bool AllowUnsecuredTransport { get; set; } /// - /// Whether to attempt to implicitly add trust for developer certificates (currently the ASP.NET developer certificate) - /// by default at runtime. + /// Whether to attempt to implicitly add trust for developer certificates (currently the ASP.NET development certificate) + /// by default at runtime. Disabling this option will also disable the automatic use of the developer certificate for server authentication. /// public bool? TrustDeveloperCertificate { get; set; } + /// + /// Whether to attempt to implicitly use a developer certificate (currently the ASP.NET Core development certificate) for server authentication for non-ASP.NET resources + /// by default at runtime. + /// + public bool? UseDeveloperCertificateForServerAuthentication { get; set; } + private string? ResolveProjectDirectory() { var assemblyMetadata = Assembly?.GetCustomAttributes(); diff --git a/src/Aspire.Hosting/IDeveloperCertificateService.cs b/src/Aspire.Hosting/IDeveloperCertificateService.cs index 25ec3f8360e..8e1e828b70b 100644 --- a/src/Aspire.Hosting/IDeveloperCertificateService.cs +++ b/src/Aspire.Hosting/IDeveloperCertificateService.cs @@ -26,10 +26,10 @@ public interface IDeveloperCertificateService bool SupportsContainerTrust { get; } /// - /// Indicates whether the available developer certificates support being used for TLS termination and should - /// be used by default if not explicitly disabled or overriden. + /// Indicates whether the default behavior is to attempt to use a developer certificate for server + /// authentication (i.e. TLS termination). /// - bool DefaultTlsTerminationEnabled { get; } + bool UseForServerAuthentication { get; } /// /// Indicates whether the default behavior is to attempt to trust the developer certificate(s) at runtime. diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 388ad604edd..8821df745db 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -47,6 +47,7 @@ internal static class KnownConfigNames public const string ExtensionDebugSessionId = "ASPIRE_EXTENSION_DEBUG_SESSION_ID"; public const string TrustDeveloperCertificate = "ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_TRUST"; + public const string UseDeveloperCertificateForServerAuthentication = "ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_SERVER_AUTHENTICATION"; public const string DebugSessionInfo = "DEBUG_SESSION_INFO"; public const string DebugSessionRunMode = "DEBUG_SESSION_RUN_MODE"; From e453337795c7db70df4e7ec7483cdb3806731478 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sun, 23 Nov 2025 17:04:50 -0800 Subject: [PATCH 03/10] Cache dev cert private keys on Mac to prevent constant keychain prompting --- .../ApplicationModel/ExpressionResolver.cs | 2 + .../ApplicationModel/ReferenceExpression.cs | 4 +- .../ApplicationModel/ResourceExtensions.cs | 8 +- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 204 +++++++++++------- src/Aspire.Hosting/Dcp/Model/Container.cs | 6 +- 5 files changed, 140 insertions(+), 84 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs index da51ee05c24..16117a21031 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs @@ -41,6 +41,8 @@ async Task EvalExpressionAsync(ReferenceExpression expr, ValuePro var args = new object?[expr.ValueProviders.Count]; var isSensitive = false; + expr.WasResolved = true; + for (var i = 0; i < expr.ValueProviders.Count; i++) { var result = await ResolveInternalAsync(expr.ValueProviders[i], context).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index df7ab6552d0..eafdf3240ea 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -66,7 +66,7 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri /// /// Indicates whether this expression was ever referenced to get its value. /// - internal bool WasReferenced { get; private set; } + internal bool WasResolved { get; set; } /// /// Gets the value of the expression. The final string value after evaluating the format string and its parameters. @@ -75,7 +75,7 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri /// A . public async ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken) { - WasReferenced = true; + WasResolved = true; // NOTE: any logical changes to this method should also be made to ExpressionResolver.EvalExpressionAsync if (Format.Length == 0) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 038a21093b7..179adfcfaee 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -757,12 +757,12 @@ internal sealed class ServerAuthenticationCertificateConfigurationDetails /// /// Indicates whether the resource references a PEM key for server authentication. /// - public required bool ReferencesPemKey { get; set; } + public required ReferenceExpression KeyPathReference { get; set; } /// /// Indicates whether the resource references a PFX file for server authentication. /// - public required bool ReferencesPfx { get; set; } + public required ReferenceExpression PfxReference { get; set; } /// /// The passphrase for the server authentication certificate, if any. @@ -845,8 +845,8 @@ internal sealed class ServerAuthenticationCertificateConfigurationDetails { Certificate = certificate, Password = password, - ReferencesPemKey = context.KeyPath.WasReferenced, - ReferencesPfx = context.PfxPath.WasReferenced, + KeyPathReference = context.KeyPath, + PfxReference = context.PfxPath, }); } diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 9caece0f44e..2c4f2caa113 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -55,8 +55,8 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I // it probably means DCP crashed and there is no point trying further. private static readonly TimeSpan s_disposeTimeout = TimeSpan.FromSeconds(10); - // Well-known location on disk where dev-certs are stored. - private static readonly string s_macOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https"); + // Well-known location on disk where dev-cert key material is cached. + private static readonly string s_macOSUserDevCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire", "dev-certs", "https"); // Regex for normalizing application names. [GeneratedRegex("""^(?.+?)\.?AppHost$""", RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant)] @@ -81,7 +81,7 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I private readonly IDeveloperCertificateService _developerCertificateService; private readonly DcpResourceState _resourceState; private readonly ResourceSnapshotBuilder _snapshotBuilder; - private readonly ConcurrentDictionary _serverAuthenticationPemKeyCache = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _serverAuthenticationKeyCache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _serverAuthenticationPfxCache = new(StringComparer.Ordinal); private readonly SemaphoreSlim _serverCertificateCacheSemaphore = new(1, 1); @@ -290,7 +290,7 @@ public async ValueTask DisposeAsync() var disposeCts = new CancellationTokenSource(); disposeCts.CancelAfter(s_disposeTimeout); _serverCertificateCacheSemaphore.Dispose(); - _serverAuthenticationPemKeyCache.Clear(); + _serverAuthenticationKeyCache.Clear(); _serverAuthenticationPfxCache.Clear(); await StopAsync(disposeCts.Token).ConfigureAwait(false); } @@ -1936,8 +1936,8 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour buildCreateFilesContext.ServerAuthenticationCertificateContext = new ContainerFileSystemCallbackServerAuthenticationCertificateContext { CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.crt"), - KeyPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.key"), - PfxPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.pfx"), + KeyPath = configContext.ServerAuthenticationCertificateConfiguration.KeyPathReference, + PfxPath = configContext.ServerAuthenticationCertificateConfiguration.PfxReference, Password = configContext.ServerAuthenticationCertificateConfiguration.Password, }; } @@ -1947,13 +1947,6 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour buildCreateFilesContext, cancellationToken).ConfigureAwait(false); - if (configContext.ServerAuthenticationCertificateConfiguration is not null) - { - // Determine if the pfx is required - configContext.ServerAuthenticationCertificateConfiguration.ReferencesPemKey = configContext.ServerAuthenticationCertificateConfiguration.ReferencesPemKey || buildCreateFilesContext.ServerAuthenticationCertificateContext?.KeyPath?.WasReferenced == true; - configContext.ServerAuthenticationCertificateConfiguration.ReferencesPfx = configContext.ServerAuthenticationCertificateConfiguration.ReferencesPfx || buildCreateFilesContext.ServerAuthenticationCertificateContext?.PfxPath?.WasReferenced == true; - } - if (configContext.ServerAuthenticationCertificateConfiguration is not null) { var thumbprint = configContext.ServerAuthenticationCertificateConfiguration.Certificate.Thumbprint; @@ -1970,13 +1963,6 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour } }; - var baseOutputPath = Path.Join(_locations.DcpSessionDir, dcpContainerResource.Name(), "private"); - if (spec.Persistent == true) - { - // Need to write the pfx cert to a persistent location so it won't trigger a lifecycle key invalidation - baseOutputPath = Path.Join(Path.GetTempPath(), "aspire", _configuration["AppHost:Sha256"], spec.ContainerName, "private"); - } - if (keyPem is not null) { certificateFiles.Add(new ContainerFileSystemEntry @@ -1991,23 +1977,11 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour if (pfxBytes is not null) { - // The PFX file is binary, so we need to write it to a temp file first - var pfxOutputPath = Path.Join(baseOutputPath, $"{thumbprint}.pfx"); - if (OperatingSystem.IsWindows()) - { - Directory.CreateDirectory(baseOutputPath); - } - else - { - Directory.CreateDirectory(baseOutputPath, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); - } - File.WriteAllBytes(pfxOutputPath, pfxBytes); - certificateFiles.Add(new ContainerFileSystemEntry { Name = thumbprint + ".pfx", Type = ContainerFileSystemEntryType.File, - Source = pfxOutputPath, + RawContents = Convert.ToBase64String(pfxBytes), }); Array.Clear(pfxBytes, 0, pfxBytes.Length); @@ -2511,72 +2485,148 @@ await modelResource.ProcessContainerRuntimeArgValues( await _serverCertificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) + if (configuration.KeyPathReference.WasResolved) { - // On macOS the ASP.NET Core dev cert is cached on disk to avoid excessive prompting for access to the keychain. - // Check to see if the current developer certificate exists in the cache and use that copy instead if available. - if (Directory.Exists(s_macOSUserHttpsCertificateLocation)) + if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) { - var candidateCachedCertificate = Path.Combine(s_macOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx"); - if (File.Exists(candidateCachedCertificate)) + // On MacOS, we cache development certificate key material to avoid triggering repeated keychain prompts + // when referencing the development certificate key. We don't do this for other OSes or other certificates. + try { - try + if (Directory.Exists(s_macOSUserDevCertificateLocation)) { - var cachedCertificate = new X509Certificate2(candidateCachedCertificate); - - if (cachedCertificate.IsAspNetCoreDevelopmentCertificate() && cachedCertificate.Thumbprint.Equals(certificate.Thumbprint, StringComparison.Ordinal)) + var keyFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.key"); + // Attempt to read the cached development certificate key + if (File.Exists(keyFileName)) { - certificate = cachedCertificate; + var keyCandidate = File.ReadAllText(keyFileName); + + /*using var tempKey = RSA.Create(); + if (configuration.Password is null) + { + tempKey.ImportFromPem(keyCandidate); + } + else + { + tempKey.ImportFromEncryptedPem(keyCandidate, configuration.Password); + } + + // Just to validate that this is the correct private key; if not an exception will be thrown + // and we'll proceed to re-export from the given certificate + certificate.CopyWithPrivateKey(tempKey);*/ + + pemKey = keyCandidate.ToCharArray(); } } - catch - { - // Ignore exceptions and continue with the original certificate - } + } + catch + { + // Ignore errors and retrieve the key from the certificate } } - } - if (configuration.ReferencesPemKey) - { - pemKey = _serverAuthenticationPemKeyCache.GetOrAdd(lookup, _ => + if (pemKey is null) { - // See: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/CertificateGeneration/CertificateManager.cs - using var privateKey = certificate.GetRSAPrivateKey(); - if (privateKey is null) + pemKey = _serverAuthenticationKeyCache.GetOrAdd(lookup, _ => { - throw new InvalidOperationException("The certificate does not have an associated RSA private key."); - } + // See: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/CertificateGeneration/CertificateManager.cs + using var privateKey = certificate.GetRSAPrivateKey(); + if (privateKey is null) + { + throw new InvalidOperationException("The certificate does not have an associated RSA private key."); + } - var keyBytes = privateKey.ExportEncryptedPkcs8PrivateKey( - configuration.Password ?? string.Empty, - new PbeParameters( - PbeEncryptionAlgorithm.Aes256Cbc, - HashAlgorithmName.SHA256, - iterationCount: configuration.Password is null ? 1 : 100_000)); - var pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + var keyBytes = privateKey.ExportEncryptedPkcs8PrivateKey( + configuration.Password ?? string.Empty, + new PbeParameters( + PbeEncryptionAlgorithm.Aes256Cbc, + HashAlgorithmName.SHA256, + iterationCount: configuration.Password is null ? 1 : 100_000)); + var pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + + if (configuration.Password is null) + { + using var tempKey = RSA.Create(); + tempKey.ImportFromEncryptedPem(pem, string.Empty); + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + keyBytes = tempKey.ExportPkcs8PrivateKey(); + pem = PemEncoding.Write("PRIVATE KEY", keyBytes); + } - if (configuration.Password is null) - { - using var tempKey = RSA.Create(); - tempKey.ImportFromEncryptedPem(pem, string.Empty); Array.Clear(keyBytes, 0, keyBytes.Length); - Array.Clear(pem, 0, pem.Length); - keyBytes = tempKey.ExportPkcs8PrivateKey(); - pem = PemEncoding.Write("PRIVATE KEY", keyBytes); - } - Array.Clear(keyBytes, 0, keyBytes.Length); + return pem; + }); + } - return pem; - }); + if (pemKey is not null && OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) + { + try + { + // Create the directory for storing macOS user dev certificates if it doesn't exist + Directory.CreateDirectory(s_macOSUserDevCertificateLocation, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); + + var keyFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.key"); + await File.WriteAllTextAsync(keyFileName, new string(pemKey), cancellationToken).ConfigureAwait(false); + } + catch + { + // This is a best effort caching operation + } + } } - if (configuration.ReferencesPfx) + if (configuration.PfxReference.WasResolved) { pfxBytes = _serverAuthenticationPfxCache.GetOrAdd(lookup, _ => { - return certificate.Export(X509ContentType.Pfx, configuration.Password); + if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) + { + // On MacOS, we cache development certificate key material to avoid triggering repeated keychain prompts + // when referencing the development certificate key. We don't do this for other OSes or other certificates. + try + { + if (Directory.Exists(s_macOSUserDevCertificateLocation)) + { + var pfxFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.pfx"); + // Attempt to read the cached development certificate key + if (File.Exists(pfxFileName)) + { + var pfxCandidate = File.ReadAllBytes(pfxFileName); + using var tempCert = new X509Certificate2(pfxCandidate, configuration.Password); + if (tempCert.Thumbprint.Equals(certificate.Thumbprint, StringComparison.Ordinal)) + { + return pfxCandidate; + } + } + } + } + catch + { + // Ignore errors and retrieve the key from the certificate + } + } + + var pfx = certificate.Export(X509ContentType.Pfx, configuration.Password); + + if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) + { + try + { + // Create the directory for storing macOS user dev certificates if it doesn't exist + Directory.CreateDirectory(s_macOSUserDevCertificateLocation, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); + + var keyFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.pfx"); + File.WriteAllBytes(keyFileName, pfx); + } + catch + { + // This is a best effort caching operation + } + } + + return pfx; }); } } diff --git a/src/Aspire.Hosting/Dcp/Model/Container.cs b/src/Aspire.Hosting/Dcp/Model/Container.cs index 8c985dcf139..5de2242c506 100644 --- a/src/Aspire.Hosting/Dcp/Model/Container.cs +++ b/src/Aspire.Hosting/Dcp/Model/Container.cs @@ -404,10 +404,14 @@ internal sealed class ContainerFileSystemEntry : IEquatable? Entries { get; set; } From 64bffd25365e4ad7353e54457735c747989a4310 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sun, 23 Nov 2025 17:37:20 -0800 Subject: [PATCH 04/10] Simplify cache to only cover MacOS --- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 151 +++++++++++--------------- 1 file changed, 63 insertions(+), 88 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 2c4f2caa113..549d517c94d 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -81,8 +81,6 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I private readonly IDeveloperCertificateService _developerCertificateService; private readonly DcpResourceState _resourceState; private readonly ResourceSnapshotBuilder _snapshotBuilder; - private readonly ConcurrentDictionary _serverAuthenticationKeyCache = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _serverAuthenticationPfxCache = new(StringComparer.Ordinal); private readonly SemaphoreSlim _serverCertificateCacheSemaphore = new(1, 1); private readonly string _normalizedApplicationName; @@ -290,8 +288,6 @@ public async ValueTask DisposeAsync() var disposeCts = new CancellationTokenSource(); disposeCts.CancelAfter(s_disposeTimeout); _serverCertificateCacheSemaphore.Dispose(); - _serverAuthenticationKeyCache.Clear(); - _serverAuthenticationPfxCache.Clear(); await StopAsync(disposeCts.Token).ConfigureAwait(false); } @@ -2487,34 +2483,20 @@ await modelResource.ProcessContainerRuntimeArgValues( { if (configuration.KeyPathReference.WasResolved) { + var keyFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.key"); if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) { // On MacOS, we cache development certificate key material to avoid triggering repeated keychain prompts // when referencing the development certificate key. We don't do this for other OSes or other certificates. try { - if (Directory.Exists(s_macOSUserDevCertificateLocation)) + // Attempt to read the cached development certificate key + if (File.Exists(keyFileName)) { - var keyFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.key"); - // Attempt to read the cached development certificate key - if (File.Exists(keyFileName)) - { - var keyCandidate = File.ReadAllText(keyFileName); - - /*using var tempKey = RSA.Create(); - if (configuration.Password is null) - { - tempKey.ImportFromPem(keyCandidate); - } - else - { - tempKey.ImportFromEncryptedPem(keyCandidate, configuration.Password); - } - - // Just to validate that this is the correct private key; if not an exception will be thrown - // and we'll proceed to re-export from the given certificate - certificate.CopyWithPrivateKey(tempKey);*/ + var keyCandidate = File.ReadAllText(keyFileName); + if (!string.IsNullOrEmpty(keyCandidate)) + { pemKey = keyCandidate.ToCharArray(); } } @@ -2527,107 +2509,100 @@ await modelResource.ProcessContainerRuntimeArgValues( if (pemKey is null) { - pemKey = _serverAuthenticationKeyCache.GetOrAdd(lookup, _ => + // See: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/CertificateGeneration/CertificateManager.cs + using var privateKey = certificate.GetRSAPrivateKey(); + if (privateKey is null) { - // See: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/CertificateGeneration/CertificateManager.cs - using var privateKey = certificate.GetRSAPrivateKey(); - if (privateKey is null) - { - throw new InvalidOperationException("The certificate does not have an associated RSA private key."); - } - - var keyBytes = privateKey.ExportEncryptedPkcs8PrivateKey( - configuration.Password ?? string.Empty, - new PbeParameters( - PbeEncryptionAlgorithm.Aes256Cbc, - HashAlgorithmName.SHA256, - iterationCount: configuration.Password is null ? 1 : 100_000)); - var pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + throw new InvalidOperationException("The certificate does not have an associated RSA private key."); + } - if (configuration.Password is null) - { - using var tempKey = RSA.Create(); - tempKey.ImportFromEncryptedPem(pem, string.Empty); - Array.Clear(keyBytes, 0, keyBytes.Length); - Array.Clear(pem, 0, pem.Length); - keyBytes = tempKey.ExportPkcs8PrivateKey(); - pem = PemEncoding.Write("PRIVATE KEY", keyBytes); - } + var keyBytes = privateKey.ExportEncryptedPkcs8PrivateKey( + configuration.Password ?? string.Empty, + new PbeParameters( + PbeEncryptionAlgorithm.Aes256Cbc, + HashAlgorithmName.SHA256, + iterationCount: configuration.Password is null ? 1 : 100_000)); + pemKey = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + if (configuration.Password is null) + { + using var tempKey = RSA.Create(); + 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); + } - return pem; - }); - } + Array.Clear(keyBytes, 0, keyBytes.Length); - if (pemKey is not null && OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) - { - try + if (pemKey is not null && OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) { - // Create the directory for storing macOS user dev certificates if it doesn't exist - Directory.CreateDirectory(s_macOSUserDevCertificateLocation, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); + // On Mac, cache the development certificate key material if we had to load it from the keychain + try + { + // Create the directory for storing macOS user dev certificates if it doesn't exist + Directory.CreateDirectory(s_macOSUserDevCertificateLocation, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); - var keyFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.key"); - await File.WriteAllTextAsync(keyFileName, new string(pemKey), cancellationToken).ConfigureAwait(false); - } - catch - { - // This is a best effort caching operation + await File.WriteAllTextAsync(keyFileName, new string(pemKey), cancellationToken).ConfigureAwait(false); + } + catch + { + // This is a best effort caching operation + } } } } if (configuration.PfxReference.WasResolved) { - pfxBytes = _serverAuthenticationPfxCache.GetOrAdd(lookup, _ => + var pfxFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.pfx"); + if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) { - if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) + // On MacOS, we cache development certificate key material to avoid triggering repeated keychain prompts + // when referencing the development certificate key. We don't do this for other OSes or other certificates. + try { - // On MacOS, we cache development certificate key material to avoid triggering repeated keychain prompts - // when referencing the development certificate key. We don't do this for other OSes or other certificates. - try + // Attempt to read the cached development certificate key + if (File.Exists(pfxFileName)) { - if (Directory.Exists(s_macOSUserDevCertificateLocation)) + var pfxCandidate = File.ReadAllBytes(pfxFileName); + if (pfxCandidate.Length > 0) { - var pfxFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.pfx"); - // Attempt to read the cached development certificate key - if (File.Exists(pfxFileName)) + using var tempCert = new X509Certificate2(pfxCandidate, configuration.Password); + if (tempCert.Thumbprint.Equals(certificate.Thumbprint, StringComparison.Ordinal)) { - var pfxCandidate = File.ReadAllBytes(pfxFileName); - using var tempCert = new X509Certificate2(pfxCandidate, configuration.Password); - if (tempCert.Thumbprint.Equals(certificate.Thumbprint, StringComparison.Ordinal)) - { - return pfxCandidate; - } + pfxBytes = pfxCandidate; } } } - catch - { - // Ignore errors and retrieve the key from the certificate - } } + catch + { + // Ignore errors and retrieve the key from the certificate + } + } - var pfx = certificate.Export(X509ContentType.Pfx, configuration.Password); + if (pfxBytes is null) + { + // On Mac, cache the development certificate pfx if we had to export it from the keychain + pfxBytes = certificate.Export(X509ContentType.Pfx, configuration.Password); - if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) + if (pfxBytes is not null && OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) { try { // Create the directory for storing macOS user dev certificates if it doesn't exist Directory.CreateDirectory(s_macOSUserDevCertificateLocation, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); - var keyFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.pfx"); - File.WriteAllBytes(keyFileName, pfx); + File.WriteAllBytes(pfxFileName, pfxBytes); } catch { // This is a best effort caching operation } } - - return pfx; - }); + } } } finally From 540e423ad658a8632bf7484ff883b8102f607f18 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sun, 23 Nov 2025 17:41:42 -0800 Subject: [PATCH 05/10] Remove X509CertificateResource --- .../X509CertificateResource.cs | 21 ------------------- 1 file changed, 21 deletions(-) delete mode 100644 src/Aspire.Hosting/ApplicationModel/X509CertificateResource.cs diff --git a/src/Aspire.Hosting/ApplicationModel/X509CertificateResource.cs b/src/Aspire.Hosting/ApplicationModel/X509CertificateResource.cs deleted file mode 100644 index 9d113dad08c..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/X509CertificateResource.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.CodeAnalysis; -using Aspire.Hosting.ApplicationModel; - -/// -/// Represents a X509 Certificate resource. This may be backed by a local certificate in run mode or a remote certificate in deploy mode. -/// -[Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] -public sealed class X509CertificateResource : Resource -{ - /// - /// Initializes a new instance of the class with the specified name. - /// - /// The name of the resource. - public X509CertificateResource(string name) : base(name) - { - ArgumentNullException.ThrowIfNull(name); - } -} \ No newline at end of file From 51053cdefbc46ce9fa93f4cde0007b0b45e3435f Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sun, 23 Nov 2025 17:45:06 -0800 Subject: [PATCH 06/10] Improve doc comments --- .../ContainerFileSystemCallbackAnnotation.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs index ddee6c94f6c..a35438aa41c 100644 --- a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs @@ -292,22 +292,25 @@ public sealed class ContainerFileSystemCallbackContext public sealed class ContainerFileSystemCallbackServerAuthenticationCertificateContext { /// - /// The path to the server authentication certificate file inside the container. + /// A reference expression that resolves to the path to the server authentication certificate file inside the container. + /// Use GetValueAsync to resolve the path. /// public ReferenceExpression CertificatePath { get; init; } = null!; /// - /// The path to the server authentication key file inside the container. + /// A reference expression that resolves to the path to the server authentication key file inside the container. + /// Use GetValueAsync to resolve the path. /// public ReferenceExpression KeyPath { get; init; } = null!; /// - /// The path to the server authentication PFX file inside the container. + /// A reference expression that resolves to the path to the server authentication PFX file inside the container. + /// Use GetValueAsync to resolve the path. /// public ReferenceExpression PfxPath { get; init; } = null!; /// - /// The password for the server authentication PFX file inside the container. + /// The password for the server authentication key inside the container or null if no password is required. /// public string? Password { get; init; } } \ No newline at end of file From e97c4f5a5426e677b7e836553117ef27a436846d Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sun, 23 Nov 2025 17:48:26 -0800 Subject: [PATCH 07/10] Ensure the booleans are evaluated correctly --- src/Aspire.Hosting/DeveloperCertificateService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index c2e49967eec..d69e3ac3891 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -77,9 +77,9 @@ public DeveloperCertificateService(ILogger logger, true; // By default, only use for server authentication if trust is also enabled (and a developer certificate with a private key is available) - UseForServerAuthentication = configuration.GetBool(KnownConfigNames.UseDeveloperCertificateForServerAuthentication) ?? + UseForServerAuthentication = (configuration.GetBool(KnownConfigNames.UseDeveloperCertificateForServerAuthentication) ?? options.UseDeveloperCertificateForServerAuthentication ?? - true && TrustCertificate && _supportsTlsTermination.Value; + true ) && TrustCertificate && _supportsTlsTermination.Value; } /// From 9ecbc628b68c48ef0073c7701058e0adce6d4d97 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sun, 23 Nov 2025 18:56:40 -0800 Subject: [PATCH 08/10] Fix test issues --- .../Utils/TestDeveloperCertificateService.cs | 5 +++-- tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs | 8 ++++---- tests/Shared/TemplatesTesting/BuildEnvironment.cs | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDeveloperCertificateService.cs b/tests/Aspire.Hosting.Tests/Utils/TestDeveloperCertificateService.cs index 8cf7237b79b..fbf9fa02632 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDeveloperCertificateService.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDeveloperCertificateService.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Tests.Utils; #pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -public sealed class TestDeveloperCertificateService(List certificates, bool supportsContainerTrust, bool trustCertificate, bool supportsTlsTermination) : IDeveloperCertificateService +public sealed class TestDeveloperCertificateService(List certificates, bool supportsContainerTrust, bool trustCertificate, bool tlsTerminate) : IDeveloperCertificateService #pragma warning restore ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. { /// @@ -19,5 +19,6 @@ public sealed class TestDeveloperCertificateService(List certi /// public bool TrustCertificate => trustCertificate; - public bool DefaultTlsTerminationEnabled => !OperatingSystem.IsMacOS() && supportsTlsTermination && trustCertificate; + /// + public bool UseForServerAuthentication => !OperatingSystem.IsMacOS() && tlsTerminate && trustCertificate; } diff --git a/tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs b/tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs index 7505480780b..bc1494b7b5a 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs +++ b/tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs @@ -44,7 +44,7 @@ public async Task VerifyRunEnvVariablesAreSet(bool containerCertificateSupport) new List(), containerCertificateSupport, trustCertificate: true, - supportsTlsTermination: false)); + tlsTerminate: false)); testProvider.AddService(new DistributedApplicationOptions()); testProvider.AddService(Options.Create(new DcpOptions())); @@ -93,7 +93,7 @@ public async Task VerifyWithStaticFilesAddsEnvironmentVariable() new List(), supportsContainerTrust: false, trustCertificate: true, - supportsTlsTermination: false)); + tlsTerminate: false)); testProvider.AddService(new DistributedApplicationOptions()); testProvider.AddService(Options.Create(new DcpOptions())); @@ -116,7 +116,7 @@ public async Task VerifyWithStaticFilesWorksInPublishOperation() new List(), supportsContainerTrust: false, trustCertificate: true, - supportsTlsTermination: false)); + tlsTerminate: false)); testProvider.AddService(new DistributedApplicationOptions()); var yarp = builder.AddYarp("yarp").WithStaticFiles(); @@ -138,7 +138,7 @@ public async Task VerifyWithStaticFilesBindMountAddsEnvironmentVariable() new List(), supportsContainerTrust: false, trustCertificate: true, - supportsTlsTermination: false)); + tlsTerminate: false)); testProvider.AddService(new DistributedApplicationOptions()); testProvider.AddService(Options.Create(new DcpOptions())); diff --git a/tests/Shared/TemplatesTesting/BuildEnvironment.cs b/tests/Shared/TemplatesTesting/BuildEnvironment.cs index 7cfb42947b9..6c4b91bda13 100644 --- a/tests/Shared/TemplatesTesting/BuildEnvironment.cs +++ b/tests/Shared/TemplatesTesting/BuildEnvironment.cs @@ -186,8 +186,8 @@ public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotne if (OperatingSystem.IsMacOS()) { - // Disable default developer certificate features in MacOS due to test performance issues - EnvVars["ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_TRUST"] = "false"; + // Disable default developer certificate server authentication in MacOS due to test performance issues + EnvVars["ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_SERVER_AUTHENTICATION"] = "false"; } DotNet = Path.Combine(sdkForTemplatePath!, "dotnet"); From 820d7b81e9afc83dd69b515b645366e46fc72e45 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sun, 23 Nov 2025 19:27:39 -0800 Subject: [PATCH 09/10] Update compatibility suppression file --- src/Aspire.Hosting/CompatibilitySuppressions.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index f15122d7061..a2457b76188 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -3,7 +3,7 @@ CP0006 - P:Aspire.Hosting.IDeveloperCertificateService.DefaultTlsTerminationEnabled + P:Aspire.Hosting.IDeveloperCertificateService.UseForServerAuthentication lib/net8.0/Aspire.Hosting.dll lib/net8.0/Aspire.Hosting.dll true From 1f256d35c1fa3f64f51e0117f98971803291ee36 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 24 Nov 2025 20:42:03 -0800 Subject: [PATCH 10/10] Attempt to skip test requiring keychain access on MacOS --- .../DistributedApplicationTests.cs | 1 + ...RequiresCertificateStoreAccessAttribute.cs | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/Aspire.TestUtilities/RequiresCertificateStoreAccessAttribute.cs diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index e3684a6477f..50c2af1e45d 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -629,6 +629,7 @@ public async Task VerifyContainerCreateFile() [Fact] [RequiresDocker] + [RequiresCertificateStoreAccess] public async Task VerifyRedisWithCertificateKeyPair() { const string testName = "verify-redis-with-certificate"; diff --git a/tests/Aspire.TestUtilities/RequiresCertificateStoreAccessAttribute.cs b/tests/Aspire.TestUtilities/RequiresCertificateStoreAccessAttribute.cs new file mode 100644 index 00000000000..c369a99e6bf --- /dev/null +++ b/tests/Aspire.TestUtilities/RequiresCertificateStoreAccessAttribute.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.XUnitExtensions; + +namespace Aspire.TestUtilities; + +/// +/// Indicates that a test requires write or export access to the certificate store. +/// This is not supported on macOS currently as keychain requires user interaction for these operations. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public class RequiresCertificateStoreAccessAttribute : Attribute, ITraitAttribute +{ + // Returns true if a valid ASP.NET Core development certificate is found in the current user's certificate store. + public static bool IsSupported => !OperatingSystem.IsMacOS(); // Can't get write or export access to the keychain in the CI currently + + public string? Reason { get; init; } + public RequiresCertificateStoreAccessAttribute(string? reason = null) + { + Reason = reason; + } + + public IReadOnlyCollection> GetTraits() + { + if (!IsSupported) + { + return [new KeyValuePair(XunitConstants.Category, "failing")]; + } + + return []; + } +}