Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3c42e64
Add logging if we detect the app host is running with an untrusted de…
danegsta Jan 15, 2026
77d3818
Allow overriding the dev cert used by the dashboard
danegsta Jan 15, 2026
be562bd
Also notify the dashboard if the certificate isn't trusted
danegsta Jan 16, 2026
33f0d1e
Update src/Aspire.Hosting/DeveloperCertificateService.cs
danegsta Jan 15, 2026
7d95f07
Move forward unlocking the macos keychain and trust the cert
danegsta Jan 16, 2026
b79f3cd
Update trust check to handle MacOS
danegsta Jan 16, 2026
60fe2d2
Add link to dev-certs code
danegsta Jan 16, 2026
9bf3372
Cleanup some of the exceptions
danegsta Jan 16, 2026
1fb6076
Trusting on Mac isn't going to be an option; switch to a warning in logs
danegsta Jan 17, 2026
1783692
Use resource strings, move check and logging to DcpHost setup
danegsta Jan 24, 2026
0eb5f0c
Update how HTTPS check is determined
danegsta Jan 24, 2026
85e6e21
Use the file system service to get a temp folder
danegsta Jan 24, 2026
30f474b
Refactor DeveloperCertificateService to use ProcessSpec and ProcessUt…
Copilot Jan 24, 2026
1009421
Update test service mocks
danegsta Jan 26, 2026
ca2f7e9
Guard against the interaction service not being available
danegsta Jan 27, 2026
d2c166a
Fix merge issue in DashboardEventHandlers
danegsta Feb 25, 2026
797e82e
Fix developer cert service timeout on Mac CI
danegsta Feb 25, 2026
4021dbc
Only skip interaction service prompt if service not available
danegsta Feb 25, 2026
b972a48
Add additional test
danegsta Feb 25, 2026
2f1f17c
Fix failing test
danegsta Feb 25, 2026
c9c57f0
Disable dev cert https in mac ci tests
danegsta Feb 25, 2026
c45514a
Revert unrequired macos timeout change
danegsta Feb 25, 2026
6624ff3
Add comments explaining the dashboard certificate config
danegsta Feb 25, 2026
a2d7ec2
Only warn if there's an HTTPS/TLS endpoint
danegsta Feb 25, 2026
80cd613
Merge branch 'release/13.2' into danegsta/trustLog
danegsta Mar 4, 2026
98d854b
Cleanup how we determine certificate trust to a cross-platform approach
danegsta Mar 4, 2026
e145d44
Expose interface property such that it can be used in tests
danegsta Mar 4, 2026
41e6906
Add chain build that got removed
danegsta Mar 4, 2026
8988e3f
Check if cert is in root store on Windows
danegsta Mar 4, 2026
0415f8a
Fix check that wasn't properly detecting the lack of trusted certific…
danegsta Mar 5, 2026
6a951b3
Update src/Aspire.Hosting/Dcp/DcpHost.cs
danegsta Mar 7, 2026
12754bc
Add logging if we detect the app host is running with an untrusted de…
Copilot Mar 7, 2026
db607d3
Fix failing test after log message updated to reference aka.ms/aspire…
Copilot Mar 7, 2026
1f08c4e
Replace "ASP.NET Core Development Certificate" with "development cert…
Copilot Mar 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/Aspire.Hosting/AspireEventSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -370,4 +370,22 @@ public void StartResourceStop(string kind, string resourceName)
WriteEvent(42, kind, resourceName);
}
}

[Event(43, Level = EventLevel.Informational, Message = "Development certificate trust check is starting...")]
public void DevelopmentCertificateTrustCheckStart()
{
if (IsEnabled())
{
WriteEvent(43);
}
}

[Event(44, Level = EventLevel.Informational, Message = "Development certificate trust check completed")]
public void DevelopmentCertificateTrustCheckStop()
{
if (IsEnabled())
{
WriteEvent(44);
}
}
}
36 changes: 36 additions & 0 deletions src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only
#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only

using System.Collections.Concurrent;
using System.Diagnostics;
Expand Down Expand Up @@ -370,6 +371,9 @@
var otlpHttpEndpointUrl = options.OtlpHttpEndpointUrl;
var mcpEndpointUrl = options.McpEndpointUrl;

// Track whether any endpoint uses HTTPS
var hasHttpsEndpoint = false;
Comment thread
danegsta marked this conversation as resolved.
Outdated

eventing.Subscribe<ResourceReadyEvent>(dashboardResource, async (@event, cancellationToken) =>
{
var browserToken = options.DashboardToken;
Expand Down Expand Up @@ -424,6 +428,7 @@
if (otlpGrpcEndpointUrl != null)
{
var address = BindingAddress.Parse(otlpGrpcEndpointUrl);

dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: OtlpGrpcEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true, transport: "http2")
{
TargetHost = address.Host
Expand All @@ -433,6 +438,7 @@
if (otlpHttpEndpointUrl != null)
{
var address = BindingAddress.Parse(otlpHttpEndpointUrl);

dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: OtlpHttpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true)
{
TargetHost = address.Host
Expand All @@ -442,12 +448,42 @@
if (mcpEndpointUrl != null)
{
var address = BindingAddress.Parse(mcpEndpointUrl);

dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: McpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true)
{
TargetHost = address.Host
});
}

// Determine whether any HTTPS endpoints are configured
var hasHttpsEndpoint = dashboardResource.TryGetAnnotationsOfType<EndpointAnnotation>(out var endpoints) && endpoints.Any(e => e.UriScheme is "https");

Check failure on line 459 in src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs

View check run for this annotation

Azure Pipelines / dotnet.aspire (Build Linux)

src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs#L459

src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs(459,13): error CS0128: (NETCORE_ENGINEERING_TELEMETRY=Build) A local variable or function named 'hasHttpsEndpoint' is already defined in this scope

if (hasHttpsEndpoint &&
!dashboardResource.HasAnnotationOfType<HttpsCertificateConfigurationCallbackAnnotation>())
Comment thread
danegsta marked this conversation as resolved.
{
var developerCertificateService = executionContext.ServiceProvider.GetRequiredService<IDeveloperCertificateService>();
var trustDeveloperCertificate = developerCertificateService.TrustCertificate;
if (dashboardResource.TryGetLastAnnotation<CertificateAuthorityCollectionAnnotation>(out var certificateAuthorityAnnotation))
{
trustDeveloperCertificate = certificateAuthorityAnnotation.TrustDeveloperCertificates.GetValueOrDefault(trustDeveloperCertificate);
}

if (trustDeveloperCertificate)
{
dashboardResource.Annotations.Add(new HttpsCertificateConfigurationCallbackAnnotation(ctx =>
{
ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.CertificatePath;
ctx.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] = ctx.KeyPath;
if (ctx.Password is not null)
{
ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password;
}

return Task.CompletedTask;
Comment thread
danegsta marked this conversation as resolved.
Outdated
}));
}
}

dashboardResource.Annotations.Add(new ResourceUrlsCallbackAnnotation(c =>
{
var browserToken = options.DashboardToken;
Expand Down
60 changes: 59 additions & 1 deletion src/Aspire.Hosting/Dcp/DcpHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@

using System.Buffers;
using System.Collections;
using System.Globalization;
using System.IO.Pipelines;
using System.Net.Sockets;
using System.Text;
using Aspire.Dashboard.Utils;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Resources;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Dcp;

#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#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.
#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

internal sealed class DcpHost
{
Expand All @@ -29,6 +33,9 @@ internal sealed class DcpHost
private readonly IInteractionService _interactionService;
private readonly Locations _locations;
private readonly TimeProvider _timeProvider;
private readonly IDeveloperCertificateService _developerCertificateService;
private readonly IFileSystemService _fileSystemService;
private readonly IConfiguration _configuration;
private readonly CancellationTokenSource _shutdownCts = new();
private Task? _logProcessorTask;

Expand All @@ -48,7 +55,10 @@ public DcpHost(
IInteractionService interactionService,
Locations locations,
DistributedApplicationModel applicationModel,
TimeProvider timeProvider)
TimeProvider timeProvider,
IDeveloperCertificateService developerCertificateService,
IFileSystemService fileSystemService,
IConfiguration configuration)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<DcpHost>();
Expand All @@ -58,11 +68,15 @@ public DcpHost(
_locations = locations;
_applicationModel = applicationModel;
_timeProvider = timeProvider;
_developerCertificateService = developerCertificateService;
_fileSystemService = fileSystemService;
_configuration = configuration;
}

public async Task StartAsync(CancellationToken cancellationToken)
{
await EnsureDcpContainerRuntimeAsync(cancellationToken).ConfigureAwait(false);
await EnsureDevelopmentCertificateTrustAsync(cancellationToken).ConfigureAwait(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why in the DCP host? Doesn't seem DCP related to me.

What about doing it in an app host event? For example, Seb added detection for whether secrets are disable and you have persistent container in an app host event.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was purely because it's the place we're doing existing startup runtime dependency checks (the container health check also lives here). We may be able to decide on a better location, but for now this is a location of convenience.

EnsureDcpHostRunning();
}

Expand Down Expand Up @@ -122,6 +136,50 @@ internal async Task EnsureDcpContainerRuntimeAsync(CancellationToken cancellatio
}
}

internal async Task EnsureDevelopmentCertificateTrustAsync(CancellationToken cancellationToken)
{
AspireEventSource.Instance.DevelopmentCertificateTrustCheckStart();

try
{
// Check if the interaction service is available (dashboard enabled)
if (!_interactionService.IsAvailable)
{
return;
}
Comment thread
danegsta marked this conversation as resolved.
Outdated

// Check and warn if the developer certificate is not trusted
if (_developerCertificateService.TrustCertificate && _developerCertificateService.Certificates.Count > 0 && !await DeveloperCertificateService.IsCertificateTrustedAsync(_fileSystemService, _developerCertificateService.Certificates.First(), cancellationToken).ConfigureAwait(false))
Comment thread
danegsta marked this conversation as resolved.
Outdated
{
Comment thread
danegsta marked this conversation as resolved.
var trustLocation = "your project folder";
var appHostDirectory = _configuration["AppHost:Directory"];
if (!string.IsNullOrWhiteSpace(appHostDirectory))
{
trustLocation = $"'{appHostDirectory}'";
}

var title = InteractionStrings.DeveloperCertificateNotFullyTrustedTitle;
var message = string.Format(CultureInfo.CurrentCulture, InteractionStrings.DeveloperCertificateNotFullyTrustedMessage, trustLocation);

_logger.LogWarning("{Message}", message);
Comment thread
danegsta marked this conversation as resolved.
Outdated

Comment thread
danegsta marked this conversation as resolved.
Outdated
// Send notification to the dashboard
_ = _interactionService.PromptNotificationAsync(
title: title,
message: message,
options: new NotificationInteractionOptions
{
Intent = MessageIntent.Error,
},
cancellationToken: cancellationToken);
}
}
finally
{
AspireEventSource.Instance.DevelopmentCertificateTrustCheckStop();
}
}

public async Task StopAsync()
{
_shutdownCts.Cancel();
Expand Down
92 changes: 83 additions & 9 deletions src/Aspire.Hosting/DeveloperCertificateService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#pragma warning disable ASPIRECERTIFICATES001
#pragma warning disable ASPIREFILESYSTEM001

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
Expand All @@ -19,6 +21,10 @@ internal class DeveloperCertificateService : IDeveloperCertificateService

public DeveloperCertificateService(ILogger<DeveloperCertificateService> logger, IConfiguration configuration, DistributedApplicationOptions options)
{
TrustCertificate = configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultTrust) ??
options.TrustDeveloperCertificate ??
true;

_certificates = new Lazy<ImmutableList<X509Certificate2>>(() =>
{
try
Expand All @@ -33,26 +39,32 @@ public DeveloperCertificateService(ILogger<DeveloperCertificateService> logger,
// so we want to ensure the certificate that will be used by ASP.NET Core is the first one in the bundle.
// Match the ordering logic ASP.NET Core uses, including DateTimeOffset.Now for current time: https://github.com/dotnet/aspnetcore/blob/0aefdae365ff9b73b52961acafd227309524ce3c/src/Shared/CertificateGeneration/CertificateManager.cs#L122
var now = DateTimeOffset.Now;

// Get all valid ASP.NET Core development certificates
var validCerts = store.Certificates
.Where(c => c.IsAspNetCoreDevelopmentCertificate())
.Where(c => c.NotBefore <= now && now <= c.NotAfter)
.ToList();

// If any certificate has a Subject Key Identifier extension, exclude certificates without it
if (validCerts.Any(c => c.HasSubjectKeyIdentifier()))
{
validCerts = validCerts.Where(c => c.HasSubjectKeyIdentifier()).ToList();
}

// Take the highest version valid certificate for each unique SKI
devCerts.AddRange(
validCerts
.GroupBy(c => c.Extensions.OfType<X509SubjectKeyIdentifierExtension>().FirstOrDefault()?.SubjectKeyIdentifier)
.SelectMany(g => g.OrderByDescending(c => c.GetCertificateVersion()).ThenByDescending(c => c.NotAfter).Take(1))
.OrderByDescending(c => c.GetCertificateVersion()).ThenByDescending(c => c.NotAfter));

// Release the unused certificates
foreach (var unusedCert in validCerts.Except(devCerts))
{
unusedCert.Dispose();
}

if (devCerts.Count == 0)
{
logger.LogInformation("No ASP.NET Core developer certificates found in the CurrentUser/My certificate store.");
Expand Down Expand Up @@ -82,15 +94,10 @@ public DeveloperCertificateService(ILogger<DeveloperCertificateService> logger,
return supportsTlsTermination;
});

// Environment variable config > DistributedApplicationOptions > default true
TrustCertificate = configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultTrust) ??
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)
UseForHttps = (configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultHttpsTermination) ??
options.DeveloperCertificateDefaultHttpsTerminationEnabled ??
true ) && TrustCertificate && _supportsTlsTermination.Value;
true) && TrustCertificate && _supportsTlsTermination.Value;
}

/// <inheritdoc />
Expand All @@ -104,4 +111,71 @@ public DeveloperCertificateService(ILogger<DeveloperCertificateService> logger,

/// <inheritdoc />
public bool UseForHttps { get; }

internal static async Task<bool> IsCertificateTrustedAsync(IFileSystemService fileSystemService, X509Certificate2 certificate, CancellationToken cancellationToken = default)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Someone else will have to review this. I'm not an expert in this area.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was reviewed in the previous version of the PR that stalled out due to a test issue that's now fixed. I re-opened because the old one was targeting main instead of release/13.2.

{
ArgumentNullException.ThrowIfNull(certificate);

if (OperatingSystem.IsMacOS())
{
// On MacOS we have to verify against the Keychain
return await IsCertificateTrustedInMacOsKeychainAsync(fileSystemService, certificate, cancellationToken).ConfigureAwait(false);
}

try
{
// On Linux and Windows, we need to check if the certificate is in the Root store
using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);

var matches = store.Certificates.Find(X509FindType.FindByThumbprint, certificate.Thumbprint, validOnly: false);
Comment thread
danegsta marked this conversation as resolved.
Outdated
try
{
return matches.Count > 0;
}
finally
{
foreach (var cert in matches)
{
cert.Dispose();
}
}
}
catch
{
// Ignore errors and assume not trusted
return false;
}
}

// Use the same approach as `dotnet dev-certs` to check if the certificate is trusted in the macOS keychain
// See: https://github.com/dotnet/aspnetcore/blob/2a88012113497bac5056548f16d810738b069198/src/Shared/CertificateGeneration/MacOSCertificateManager.cs#L36-L37
private static async Task<bool> IsCertificateTrustedInMacOsKeychainAsync(IFileSystemService fileSystemService, X509Certificate2 certificate, CancellationToken cancellationToken)
{
try
{
using var tempDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-devcert-check");
var certPath = Path.Combine(tempDirectory.Path, $"aspire-devcert-{certificate.Thumbprint}.cer");

File.WriteAllBytes(certPath, certificate.Export(X509ContentType.Cert));

var processSpec = new ProcessSpec("security")
{
Arguments = $"verify-cert -p basic -p ssl -c {certPath}",
Comment thread
danegsta marked this conversation as resolved.
Outdated
ThrowOnNonZeroReturnCode = false
};

var (task, processDisposable) = ProcessUtil.Run(processSpec);
await using (processDisposable.ConfigureAwait(false))
{
var result = await task.WaitAsync(cancellationToken).ConfigureAwait(false);
return result.ExitCode == 0;
}
}
catch
{
// Ignore errors and assume not trusted
return false;
}
}
}
Loading
Loading