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 d86895d58be..a35438aa41c 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,40 @@ public sealed class ContainerFileSystemCallbackContext /// The app model resource the callback is associated with. /// public required IResource Model { get; init; } + + /// + /// The path to the server authentication certificate file inside the container. + /// + [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + 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 +{ + /// + /// 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!; + + /// + /// 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!; + + /// + /// 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 key inside the container or null if no password is required. + /// + public string? Password { get; init; } } \ No newline at end of file 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 52e30e32c2a..eafdf3240ea 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 WasResolved { get; 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) { + 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 2afcb52c165..5d976661f9a 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 ReferenceExpression KeyPathReference { get; set; } + + /// + /// Indicates whether the resource references a PFX file for server authentication. + /// + public required ReferenceExpression PfxReference { 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, + KeyPathReference = context.KeyPath, + PfxReference = context.PfxPath, + }); } internal static async ValueTask ResolveValueAsync( 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 diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 9c233dac097..549d517c94d 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-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)] private static partial Regex ApplicationNameRegex(); @@ -78,6 +81,7 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I private readonly IDeveloperCertificateService _developerCertificateService; private readonly DcpResourceState _resourceState; private readonly ResourceSnapshotBuilder _snapshotBuilder; + private readonly SemaphoreSlim _serverCertificateCacheSemaphore = new(1, 1); private readonly string _normalizedApplicationName; @@ -283,6 +287,7 @@ public async ValueTask DisposeAsync() { var disposeCts = new CancellationTokenSource(); disposeCts.CancelAfter(s_disposeTimeout); + _serverCertificateCacheSemaphore.Dispose(); await StopAsync(disposeCts.Token).ConfigureAwait(false); } @@ -1559,23 +1564,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); - Array.Clear(keyPem, 0, keyPem.Length); - Array.Clear(keyBytes, 0, keyBytes.Length); + // 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); + } + + 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 @@ -1898,55 +1919,76 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour spec.PemCertificates = pemCertificates; + var buildCreateFilesContext = new BuildCreateFilesContext + { + Resource = modelContainerResource, + CertificateTrustScope = configContext.CertificateTrustScope, + CertificateTrustBundlePath = $"{certificatesDestination}/cert.pem", + }; + + if (configContext.ServerAuthenticationCertificateConfiguration is not null) + { + var thumbprint = configContext.ServerAuthenticationCertificateConfiguration.Certificate.Thumbprint; + buildCreateFilesContext.ServerAuthenticationCertificateContext = new ContainerFileSystemCallbackServerAuthenticationCertificateContext + { + CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.crt"), + KeyPath = configContext.ServerAuthenticationCertificateConfiguration.KeyPathReference, + PfxPath = configContext.ServerAuthenticationCertificateConfiguration.PfxReference, + Password = configContext.ServerAuthenticationCertificateConfiguration.Password, + }; + } + // 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) + 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) + if (keyPem is not null) { - // 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"); + certificateFiles.Add(new ContainerFileSystemEntry + { + Name = thumbprint + ".key", + Type = ContainerFileSystemEntryType.File, + Contents = new string(keyPem), + }); + + Array.Clear(keyPem, 0, keyPem.Length); } - // 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 (pfxBytes is not null) + { + certificateFiles.Add(new ContainerFileSystemEntry + { + Name = thumbprint + ".pfx", + Type = ContainerFileSystemEntryType.File, + RawContents = Convert.ToBase64String(pfxBytes), + }); + + 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 @@ -2347,19 +2389,29 @@ 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 ContainerFileSystemCallbackServerAuthenticationCertificateContext? ServerAuthenticationCertificateContext { 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, + ServerAuthenticationCertificateContext = context.ServerAuthenticationCertificateContext, }, cancellationToken).ConfigureAwait(false); @@ -2403,38 +2455,162 @@ 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 (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 + { + // Attempt to read the cached development certificate key + if (File.Exists(keyFileName)) + { + var keyCandidate = File.ReadAllText(keyFileName); - var contents = PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert)); + if (!string.IsNullOrEmpty(keyCandidate)) + { + pemKey = keyCandidate.ToCharArray(); + } + } + } + catch + { + // Ignore errors and retrieve the key from the certificate + } + } + + 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) + { + 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)); + 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); + } - Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(keyBytes, 0, keyBytes.Length); + + if (pemKey is not null && OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) + { + // 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); + + await File.WriteAllTextAsync(keyFileName, new string(pemKey), cancellationToken).ConfigureAwait(false); + } + catch + { + // This is a best effort caching operation + } + } + } + } + + if (configuration.PfxReference.WasResolved) + { + var pfxFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.pfx"); + 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 + { + // Attempt to read the cached development certificate key + if (File.Exists(pfxFileName)) + { + var pfxCandidate = File.ReadAllBytes(pfxFileName); + if (pfxCandidate.Length > 0) + { + using var tempCert = new X509Certificate2(pfxCandidate, configuration.Password); + if (tempCert.Thumbprint.Equals(certificate.Thumbprint, StringComparison.Ordinal)) + { + pfxBytes = pfxCandidate; + } + } + } + } + catch + { + // Ignore errors and retrieve the key from the certificate + } + } + + 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 (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); + + File.WriteAllBytes(pfxFileName, pfxBytes); + } + catch + { + // This is a best effort caching operation + } + } + } + } + } + finally + { + _serverCertificateCacheSemaphore.Release(); + } - return (contents, pem); + return (pemKey, pfxBytes); } private static List BuildContainerPorts(RenderedModelResource cr) 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; } diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index 6163829cf61..d69e3ac3891 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"; 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.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/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 []; + } +} 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");