diff --git a/src/Aspire.Hosting/Dcp/DcpHost.cs b/src/Aspire.Hosting/Dcp/DcpHost.cs index cb2becf6592..fa756dbb538 100644 --- a/src/Aspire.Hosting/Dcp/DcpHost.cs +++ b/src/Aspire.Hosting/Dcp/DcpHost.cs @@ -9,6 +9,7 @@ using System.Text; using Aspire.Dashboard.Utils; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Diagnostics; using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Resources; using Aspire.Shared; @@ -38,6 +39,8 @@ internal sealed class DcpHost private readonly IConfiguration _configuration; private readonly CancellationTokenSource _shutdownCts = new(); private string? _dcpTlsCertThumbprint; + private string? _dcpTlsCertFile; + private string? _dcpTlsKeyFile; private Task? _logProcessorTask; // These environment variables should never be inherited by DCP from the app host. @@ -176,27 +179,25 @@ internal async Task EnsureDevelopmentCertificateTrustAsync(CancellationToken can } } - internal Task PrepareDcpTlsCertificateAsync(CancellationToken cancellationToken) + internal async Task PrepareDcpTlsCertificateAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - // Using the ASP.NET dev cert for DCP TLS is opt-in; by default DCP uses its own ephemeral certificate. - if (!_configuration.GetBool(KnownConfigNames.DcpDeveloperCertificate, defaultValue: false)) + // DCP uses the ASP.NET dev cert for TLS by default. The environment variable remains + // available as an opt-out if users need DCP's ephemeral certificate behavior. + if (!_configuration.GetBool(KnownConfigNames.DcpDeveloperCertificate, defaultValue: true)) { - return Task.CompletedTask; + return; } - if (!OperatingSystem.IsWindows()) - { - _logger.LogWarning("Developer certificate thumbprint configuration is only supported on Windows. DCP will use its default certificate."); - return Task.CompletedTask; - } + using var activity = ProfilingTelemetry.StartDcpPrepareTlsCertificate(_configuration); // Check if we have a trusted developer certificate with a private key available var certificates = _developerCertificateService.Certificates; if (certificates.Count == 0) { - return Task.CompletedTask; + activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultNoCertificate); + return; } // Use the first (latest/best) certificate that has a private key @@ -212,20 +213,51 @@ internal Task PrepareDcpTlsCertificateAsync(CancellationToken cancellationToken) if (certificate is null) { - return Task.CompletedTask; + activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultNoPrivateKeyCertificate); + return; } var thumbprint = certificate.Thumbprint; if (string.IsNullOrWhiteSpace(thumbprint)) { _logger.LogWarning("Failed to read the developer certificate thumbprint. DCP will use its default certificate."); - return Task.CompletedTask; + activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultMissingThumbprint); + return; } _dcpTlsCertThumbprint = thumbprint; - _logger.LogDebug("Prepared DCP TLS certificate thumbprint {Thumbprint}.", thumbprint); - return Task.CompletedTask; + if (OperatingSystem.IsWindows()) + { + activity.SetDcpTlsCertificateResult( + ProfilingTelemetry.Values.DcpTlsCertificateResultPrepared, + ProfilingTelemetry.Values.DcpTlsCertificateModeThumbprint, + prepared: true); + _logger.LogDebug("Prepared DCP TLS certificate thumbprint {Thumbprint}.", thumbprint); + return; + } + + var (certificatePath, keyPath, cachedThumbprint) = await DeveloperCertificateService.GetCachedCertificateFilePathsAsync( + certificate, + password: null, + cancellationToken).ConfigureAwait(false); + + if (certificatePath is null || keyPath is null || cachedThumbprint is null) + { + _logger.LogWarning("Failed to cache the developer certificate files. DCP will use its default certificate."); + _dcpTlsCertThumbprint = null; + activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultNoCertificate); + return; + } + + _dcpTlsCertThumbprint = cachedThumbprint; + _dcpTlsCertFile = certificatePath; + _dcpTlsKeyFile = keyPath; + activity.SetDcpTlsCertificateResult( + ProfilingTelemetry.Values.DcpTlsCertificateResultPrepared, + ProfilingTelemetry.Values.DcpTlsCertificateModeFiles, + prepared: true); + _logger.LogDebug("Prepared DCP TLS certificate files for thumbprint {Thumbprint}.", thumbprint); } public async Task StopAsync() @@ -280,6 +312,11 @@ public ProcessSpec CreateDcpProcessSpec(Locations locations) arguments += $" --tls-cert-thumbprint \"{_dcpTlsCertThumbprint}\""; } + if (!string.IsNullOrWhiteSpace(_dcpTlsCertFile) && !string.IsNullOrWhiteSpace(_dcpTlsKeyFile)) + { + arguments += $" --tls-cert-file \"{_dcpTlsCertFile}\" --tls-key-file \"{_dcpTlsKeyFile}\""; + } + var dcpProcessSpec = new ProcessSpec(dcpExePath) { WorkingDirectory = Directory.GetCurrentDirectory(), diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index 0fbe0380dad..ae440155df8 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -229,48 +229,120 @@ private static IEnumerable FindDevCertificates(X509Store store return ExportFromPrivateKey(certificate, password, needKeyPem, needPfx); } - // For dev certs we prefer reading from cache to avoid repeated keychain access prompts - var lookup = certificate.Thumbprint; - if (password is not null) + // For dev certs we prefer reading from cache to avoid repeated keychain access prompts. + // Ensure only one thread at a time is resolving certificates to avoid concurrent cache misses + // all trying to update the cache at the same time. + await s_certificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - lookup += $"-{password}"; + var cached = EnsureCachedKeyMaterial(certificate, password); + return ( + needKeyPem ? Encoding.UTF8.GetString(cached.keyBytes).ToCharArray() : null, + needPfx ? cached.pfxBytes : null); } + finally + { + s_certificateCacheSemaphore.Release(); + } + } - lookup = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(lookup))); + /// + /// Ensures the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files + /// exist for the specified ASP.NET Core developer certificate and returns the paths along with + /// the certificate thumbprint. Returns (null, null, null) if the supplied certificate is + /// not a developer certificate, has no thumbprint, or the cache files could not be produced. + /// + internal static async Task<(string? certificateFilePath, string? keyFilePath, string? thumbprint)> GetCachedCertificateFilePathsAsync( + X509Certificate2 certificate, + string? password, + CancellationToken cancellationToken) + { + if (!certificate.IsAspNetCoreDevelopmentCertificate() || string.IsNullOrWhiteSpace(certificate.Thumbprint)) + { + return (null, null, null); + } + + string certificateFileName; + string keyFileName; - // Ensure only one thread at a time is resolving certificates to avoid concurrent cache misses - // all trying to update the cache at the same time. await s_certificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - var pfxFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.pfx"); - var keyFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.key"); + var cached = EnsureCachedKeyMaterial(certificate, password); + certificateFileName = cached.certFileName; + keyFileName = cached.keyFileName; + } + finally + { + s_certificateCacheSemaphore.Release(); + } - // Try to read cached files. On cache hit, return the raw bytes directly - // without loading them into X509Certificate2 (which would import the key into - // the macOS keychain on net8.0). - var cachedPfx = TryReadCacheFile(pfxFileName); - var cachedKey = TryReadCacheFile(keyFileName); + return File.Exists(certificateFileName) && File.Exists(keyFileName) + ? (certificateFileName, keyFileName, certificate.Thumbprint) + : (null, null, null); + } - if (cachedPfx is not null && cachedKey is not null) - { - return ( - needKeyPem ? Encoding.UTF8.GetString(cachedKey).ToCharArray() : null, - needPfx ? cachedPfx : null); - } + /// + /// Ensures the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files + /// exist for the specified certificate, returning their paths along with the cached PFX/key + /// byte contents. On cache miss the private key is accessed once (which may trigger a keychain + /// prompt on macOS) to export both private-key formats; on cache hit the bytes are read + /// directly from disk to avoid importing the PFX into the macOS keychain via + /// . The public .crt file is written whenever it is missing, + /// since it can be produced without accessing the private key. + /// + /// The caller must hold . + private static (string certFileName, string pfxFileName, string keyFileName, byte[] pfxBytes, byte[] keyBytes) EnsureCachedKeyMaterial( + X509Certificate2 certificate, string? password) + { + var lookup = GetKeyMaterialCacheLookup(certificate, password); + var certFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.crt"); + var pfxFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.pfx"); + var keyFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.key"); + var cachedPfx = TryReadCacheFile(pfxFileName); + var cachedKey = TryReadCacheFile(keyFileName); + + byte[] pfxBytes; + byte[] keyBytes; + + if (cachedPfx is not null && cachedKey is not null) + { + pfxBytes = cachedPfx; + keyBytes = cachedKey; + } + else + { // Fall back to accessing the private key directly (triggers a keychain prompt on macOS). // Always produce both formats for caching, even if the caller only needs one. var result = ExportFromPrivateKey(certificate, password, needKeyPem: true, needPfx: true); + pfxBytes = result.pfxBytes!; + keyBytes = Encoding.UTF8.GetBytes(result.keyPem!); + Array.Clear(result.keyPem!); - WriteCacheFiles(pfxFileName, result.pfxBytes, keyFileName, result.keyPem); + WriteCacheFiles(certFileName, certificate, pfxFileName, pfxBytes, keyFileName, keyBytes); + } - return (needKeyPem ? result.keyPem : null, needPfx ? result.pfxBytes : null); + // The public certificate cache file can be produced without touching the private key, + // so refresh it whenever it is missing (including on cache hits from older caches that + // pre-date this file). + if (!File.Exists(certFileName)) + { + WriteCacheFiles(certFileName, certificate, pfxFileName: null, pfxBytes: null, keyFileName: null, keyBytes: null); } - finally + + return (certFileName, pfxFileName, keyFileName, pfxBytes, keyBytes); + } + + private static string GetKeyMaterialCacheLookup(X509Certificate2 certificate, string? password) + { + var lookup = certificate.Thumbprint; + if (password is not null) { - s_certificateCacheSemaphore.Release(); + lookup += $"-{password}"; } + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(lookup))); } /// @@ -353,9 +425,17 @@ private static char[] ExportKeyPem(AsymmetricAlgorithm privateKey, string? passw } /// - /// Writes PFX and PEM key cache files. Best-effort; failures are silently ignored. + /// Writes the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files. + /// Any of the file-name / payload pairs may be null to skip writing that file. Best-effort; + /// failures are silently ignored. /// - private static void WriteCacheFiles(string pfxFileName, byte[]? pfxBytes, string keyFileName, char[]? keyPem) + private static void WriteCacheFiles( + string certFileName, + X509Certificate2 certificate, + string? pfxFileName, + byte[]? pfxBytes, + string? keyFileName, + byte[]? keyBytes) { try { @@ -368,14 +448,16 @@ private static void WriteCacheFiles(string pfxFileName, byte[]? pfxBytes, string Directory.CreateDirectory(s_userDevCertificateLocation, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); } - if (pfxBytes is not null) + File.WriteAllText(certFileName, certificate.ExportCertificatePem()); + + if (pfxFileName is not null && pfxBytes is not null) { File.WriteAllBytes(pfxFileName, pfxBytes); } - if (keyPem is not null) + if (keyFileName is not null && keyBytes is not null) { - File.WriteAllBytes(keyFileName, Encoding.UTF8.GetBytes(keyPem)); + File.WriteAllBytes(keyFileName, keyBytes); } } catch diff --git a/src/Aspire.Hosting/Diagnostics/ProfilingTelemetry.cs b/src/Aspire.Hosting/Diagnostics/ProfilingTelemetry.cs index a519b6cbf7e..c049017c87b 100644 --- a/src/Aspire.Hosting/Diagnostics/ProfilingTelemetry.cs +++ b/src/Aspire.Hosting/Diagnostics/ProfilingTelemetry.cs @@ -22,6 +22,7 @@ internal static class Activities // Activity names describe AppHost/DCP orchestration work. Keep names stable // because profiling exports are queried across CLI and AppHost versions. public const string DcpRunApplication = "aspire.hosting.dcp.run_application"; + public const string DcpPrepareTlsCertificate = "aspire.hosting.dcp.prepare_tls_certificate"; public const string AppHostProcessStartup = "aspire.hosting.apphost.process_startup"; public const string AppHostStart = "aspire.hosting.apphost.start"; public const string AppHostBeforeStart = "aspire.hosting.apphost.before_start"; @@ -110,6 +111,10 @@ internal static class Tags public const string DcpKubernetesClientWaitMilliseconds = "aspire.dcp.kubernetes_client.wait_ms"; public const string DcpKubernetesClientAlreadyInitialized = "aspire.dcp.kubernetes_client_already_initialized"; public const string DcpKubernetesClientInitialized = "aspire.dcp.kubernetes_client.initialized"; + public const string DcpTlsDeveloperCertificateEnabled = "aspire.dcp.tls.developer_certificate.enabled"; + public const string DcpTlsCertificateMode = "aspire.dcp.tls.certificate.mode"; + public const string DcpTlsCertificatePrepared = "aspire.dcp.tls.certificate.prepared"; + public const string DcpTlsCertificateResult = "aspire.dcp.tls.certificate.result"; public const string BackchannelSocketPath = "aspire.hosting.backchannel.socket.path"; public const string PreviousResourceState = "aspire.resource.previous_state"; public const string PreviousResourceHealthStatus = "aspire.resource.previous_health_status"; @@ -170,6 +175,12 @@ internal static class Values public const string DashboardUrlSourceNone = "none"; public const string DashboardUrlSourceResource = "resource"; public const string DashboardUrlSourceConfiguration = "configuration"; + public const string DcpTlsCertificateModeFiles = "files"; + public const string DcpTlsCertificateModeThumbprint = "thumbprint"; + public const string DcpTlsCertificateResultMissingThumbprint = "missing_thumbprint"; + public const string DcpTlsCertificateResultNoCertificate = "no_certificate"; + public const string DcpTlsCertificateResultNoPrivateKeyCertificate = "no_private_key_certificate"; + public const string DcpTlsCertificateResultPrepared = "prepared"; } internal static class Annotations @@ -254,6 +265,13 @@ public static ActivityScope StartDcpRunApplication(IConfiguration? configuration return activity; } + public static ActivityScope StartDcpPrepareTlsCertificate(IConfiguration? configuration) + { + var activity = StartActivity(configuration, Activities.DcpPrepareTlsCertificate); + activity.SetDcpTlsDeveloperCertificateEnabled(true); + return activity; + } + public static ActivityScope StartAppHostStart(IConfiguration? configuration, string entryPoint) { var activity = StartActivity(configuration, Activities.AppHostStart); @@ -886,6 +904,19 @@ public void SetDcpKubernetesApi(DcpApiOperationType operationType, string resour SetTag(Tags.DcpResourceKind, resourceType); } + public void SetDcpTlsDeveloperCertificateEnabled(bool enabled) => SetTag(Tags.DcpTlsDeveloperCertificateEnabled, enabled); + + public void SetDcpTlsCertificateResult(string result, string? mode = null, bool prepared = false) + { + SetTag(Tags.DcpTlsCertificateResult, result); + SetTag(Tags.DcpTlsCertificatePrepared, prepared); + + if (!string.IsNullOrEmpty(mode)) + { + SetTag(Tags.DcpTlsCertificateMode, mode); + } + } + public void SetAppHostEntryPoint(string entryPoint) => SetTag(Tags.AppHostEntryPoint, entryPoint); public void SetAppHostEventSubscriberCount(int subscriberCount) => SetTag(Tags.AppHostEventSubscriberCount, subscriberCount); diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs index 9db239171b8..b47ef93926e 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs @@ -1,10 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; +using System.Diagnostics; using System.Globalization; using System.Net.Sockets; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Aspire.Hosting.Dcp; +using Aspire.Hosting.Diagnostics; using Aspire.Hosting.Resources; using Aspire.Hosting.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -474,19 +478,20 @@ public void CreateDcpProcessSpec_WithoutTlsCertThumbprint_DoesNotIncludeThumbpri // Assert Assert.DoesNotContain("--tls-cert-thumbprint", processSpec.Arguments); + Assert.DoesNotContain("--tls-cert-file", processSpec.Arguments); + Assert.DoesNotContain("--tls-key-file", processSpec.Arguments); } [Fact] - public async Task CreateDcpProcessSpec_WithTlsCertThumbprint_IncludesThumbprintArgument() + public async Task CreateDcpProcessSpec_WithDcpDeveloperCertificateDefault_IncludesDeveloperCertificateArguments() { - Assert.SkipUnless(OperatingSystem.IsWindows(), "Developer certificate thumbprint is only supported on Windows."); - - // Arrange - using var certificate = CreateUntrustedCertificate(); + var activities = new ConcurrentBag(); + using var listener = CreateActivityListener(ProfilingTelemetry.ActivitySourceName, activities.Add); + using var certificate = CreateExportableCertificate(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - [KnownConfigNames.DcpDeveloperCertificate] = "true" + [KnownConfigNames.ProfilingEnabled] = "true" }) .Build(); var dcpHost = CreateDcpHostForProcessSpecTests( @@ -501,15 +506,41 @@ public async Task CreateDcpProcessSpec_WithTlsCertThumbprint_IncludesThumbprintA // Assert Assert.Contains($"--tls-cert-thumbprint \"{certificate.Thumbprint}\"", processSpec.Arguments); + var certificateActivity = Assert.Single(activities, activity => activity.OperationName == ProfilingTelemetry.Activities.DcpPrepareTlsCertificate); + Assert.Equal(true, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsDeveloperCertificateEnabled)); + Assert.Equal(ProfilingTelemetry.Values.DcpTlsCertificateResultPrepared, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsCertificateResult)); + Assert.Equal(true, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsCertificatePrepared)); + + if (OperatingSystem.IsWindows()) + { + Assert.DoesNotContain("--tls-cert-file", processSpec.Arguments); + Assert.DoesNotContain("--tls-key-file", processSpec.Arguments); + Assert.Equal(ProfilingTelemetry.Values.DcpTlsCertificateModeThumbprint, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsCertificateMode)); + } + else + { + var certificatePath = GetQuotedArgumentValue(processSpec.Arguments, "--tls-cert-file"); + var keyPath = GetQuotedArgumentValue(processSpec.Arguments, "--tls-key-file"); + + Assert.Equal(certificate.ExportCertificatePem(), File.ReadAllText(certificatePath)); + Assert.Contains("PRIVATE KEY", File.ReadAllText(keyPath)); + Assert.Equal(ProfilingTelemetry.Values.DcpTlsCertificateModeFiles, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsCertificateMode)); + } } [Fact] public async Task CreateDcpProcessSpec_WithDcpDeveloperCertificateDisabled_DoesNotIncludeThumbprintArgument() { - // Arrange - config not set, so PrepareDcpTlsCertificateAsync should not set the thumbprint using var certificate = CreateUntrustedCertificate(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [KnownConfigNames.DcpDeveloperCertificate] = "false" + }) + .Build(); var dcpHost = CreateDcpHostForProcessSpecTests( - developerCertificateService: new TestDeveloperCertificateService([certificate], false, false, false)); + developerCertificateService: new TestDeveloperCertificateService([certificate], false, false, false), + configuration: configuration); var locations = CreateTestLocations(); await dcpHost.PrepareDcpTlsCertificateAsync(CancellationToken.None); @@ -517,8 +548,10 @@ public async Task CreateDcpProcessSpec_WithDcpDeveloperCertificateDisabled_DoesN // Act var processSpec = dcpHost.CreateDcpProcessSpec(locations); - // Assert - thumbprint should not appear because config is not enabled + // Assert - thumbprint should not appear because config is explicitly disabled Assert.DoesNotContain("--tls-cert-thumbprint", processSpec.Arguments); + Assert.DoesNotContain("--tls-cert-file", processSpec.Arguments); + Assert.DoesNotContain("--tls-key-file", processSpec.Arguments); } [Fact] @@ -685,6 +718,48 @@ private static X509Certificate2 CreateUntrustedCertificate() throw new FileNotFoundException("Could not locate test certificate file 'testCert.pfx' in expected locations."); } + private static X509Certificate2 CreateExportableCertificate() + { + var subject = new X500DistinguishedName($"CN=aspire-test-{Guid.NewGuid():N}"); + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509Extension("1.3.6.1.4.1.311.84.1.1", [0], critical: false)); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); + + return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), password: null, X509KeyStorageFlags.Exportable); + } + + private static string GetQuotedArgumentValue(string? arguments, string option) + { + if (arguments is null) + { + throw new InvalidOperationException("Expected process arguments to be set."); + } + + var prefix = $"{option} \""; + var start = arguments.IndexOf(prefix, StringComparison.Ordinal); + Assert.NotEqual(-1, start); + start += prefix.Length; + + var end = arguments.IndexOf('"', start); + Assert.NotEqual(-1, end); + + return arguments[start..end]; + } + + private static ActivityListener CreateActivityListener(string sourceName, Action activityStopped) + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activityStopped + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + [Fact] public async Task DcpHost_WithNoHttpsResources_DoesNotShowCertificateWarning() {