Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
81 changes: 81 additions & 0 deletions src/Aspire.Cli/Certificates/CertificateCacheWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Certificates;

internal static class CertificateCacheWriter
{
private const UnixFileMode DirectoryPermissions =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
private const UnixFileMode FilePermissions =
UnixFileMode.UserRead | UnixFileMode.UserWrite;

public static void WriteFile(string outputPath, ReadOnlySpan<byte> contents, ILogger logger)
{
EnsureDirectory(Path.GetDirectoryName(outputPath)!);

// Publish a fully flushed file atomically so readers never observe a partial certificate bundle.
var temporaryPath = Path.Combine(
Path.GetDirectoryName(outputPath)!,
$".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp");

try
{
var options = new FileStreamOptions
{
Access = FileAccess.Write,
Mode = FileMode.CreateNew,
Share = FileShare.None
};

if (!OperatingSystem.IsWindows())
{
#pragma warning disable CA1416 // Validate platform compatibility
options.UnixCreateMode = FilePermissions;
#pragma warning restore CA1416 // Validate platform compatibility
}

using (var stream = new FileStream(temporaryPath, options))
{
stream.Write(contents);
stream.Flush(flushToDisk: true);
}

try
{
File.Move(temporaryPath, outputPath);
}
catch (IOException) when (File.Exists(outputPath))
{
// Another process published the same content-addressed file first.
}
}
finally
{
try
{
File.Delete(temporaryPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to delete temporary certificate cache file {TemporaryPath}", temporaryPath);
}
}
}

private static void EnsureDirectory(string directory)
{
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(directory);
return;
}

#pragma warning disable CA1416 // Validate platform compatibility
Directory.CreateDirectory(directory, DirectoryPermissions);
File.SetUnixFileMode(directory, DirectoryPermissions);
#pragma warning restore CA1416 // Validate platform compatibility
}
}
36 changes: 35 additions & 1 deletion src/Aspire.Cli/Certificates/CertificateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Certificates;

Expand Down Expand Up @@ -42,16 +43,22 @@ internal sealed class EnsureCertificatesTrustedResult
internal interface ICertificateService
{
Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(CancellationToken cancellationToken);

string? ExportDevCertificatePem(CancellationToken cancellationToken);
}

internal sealed class CertificateService(
ICertificateToolRunner certificateToolRunner,
IInteractionService interactionService,
AspireCliTelemetry telemetry,
ICliHostEnvironment hostEnvironment,
IEnvironment environment) : ICertificateService
IEnvironment environment,
CliExecutionContext executionContext,
ILogger<CertificateService> logger) : ICertificateService
{
private const string SslCertDirEnvVar = "SSL_CERT_DIR";
internal string DevCertDirectory => Path.Combine(
executionContext.AspireHomeDirectory.FullName, "dev-certs");

public async Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -197,6 +204,33 @@ private void ConfigureSslCertDir(Dictionary<string, string> environmentVariables
environmentVariables[SslCertDirEnvVar] = string.Join(Path.PathSeparator, systemCertDirs);
}
}

public string? ExportDevCertificatePem(CancellationToken cancellationToken)
{
try
{
var result = certificateToolRunner.ExportDevCertificatePublicPem(DevCertDirectory, cancellationToken);
if (result is not null)
{
logger.LogDebug("Exported dev certificate public PEM to {Path}", result);
}
else
{
logger.LogDebug("No valid dev certificate found to export as PEM");
}

return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to export dev certificate as PEM");
return null;
}
}
}

internal sealed class CertificateServiceException(string message) : Exception(message)
Expand Down
9 changes: 9 additions & 0 deletions src/Aspire.Cli/Certificates/ICertificateToolRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,13 @@ internal interface ICertificateToolRunner
/// Removes all HTTPS development certificates.
/// </summary>
CertificateCleanResult CleanHttpCertificate();

/// <summary>
/// Exports the highest-versioned trusted ASP.NET Core HTTPS development certificate
/// as a content-addressed PEM file in the specified directory.
/// </summary>
/// <param name="outputDirectory">The directory where the PEM certificate should be cached.</param>
/// <param name="cancellationToken">A token that can be used to cancel the operation.</param>
/// <returns>The output path if a certificate was exported; <see langword="null"/> if no valid certificate was found.</returns>
string? ExportDevCertificatePublicPem(string outputDirectory, CancellationToken cancellationToken = default);
}
79 changes: 72 additions & 7 deletions src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.IO.Hashing;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Certificates;

/// <summary>
/// Certificate tool runner that uses the native CertificateManager directly (no subprocess needed).
/// </summary>
internal sealed class NativeCertificateToolRunner(CertificateManager certificateManager, IEnvironment environment) : ICertificateToolRunner
internal sealed class NativeCertificateToolRunner(
CertificateManager certificateManager,
IEnvironment environment,
ILogger<NativeCertificateToolRunner> logger) : ICertificateToolRunner
{

public CertificateTrustResult CheckHttpCertificate(CancellationToken cancellationToken = default)
{
var availableCertificates = certificateManager.ListCertificates(
Expand All @@ -30,13 +36,11 @@ public CertificateTrustResult CheckHttpCertificate(CancellationToken cancellatio
{
trustLevel = CertificateManager.TrustLevel.None;
}
else if (certificateManager is UnixCertificateManager unixCertificateManager)
{
trustLevel = unixCertificateManager.GetTrustLevel(cert, cancellationToken);
}
else
{
trustLevel = certificateManager.GetTrustLevel(cert);
trustLevel = certificateManager is UnixCertificateManager unixCertificateManager
? unixCertificateManager.GetTrustLevel(cert, cancellationToken)
: certificateManager.GetTrustLevel(cert);
}

return new DevCertInfo
Expand Down Expand Up @@ -192,6 +196,67 @@ public CertificateCleanResult CleanHttpCertificate()
}
}

public string? ExportDevCertificatePublicPem(string outputDirectory, CancellationToken cancellationToken = default)
{
logger.LogDebug("Searching for a trusted ASP.NET Core development certificate to export");

var availableCertificates = certificateManager.ListCertificates(
StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false);

try
{
var now = DateTimeOffset.Now;
var validCertificates = availableCertificates
.Where(c => c.HasPrivateKey && c.NotBefore <= now && now <= c.NotAfter)
.ToList();

if (validCertificates.Any(c => c.HasSubjectKeyIdentifier()))
{
validCertificates = validCertificates.Where(c => c.HasSubjectKeyIdentifier()).ToList();
}

var certificate = validCertificates
.GroupBy(c => c.Extensions.OfType<X509SubjectKeyIdentifierExtension>().FirstOrDefault()?.SubjectKeyIdentifier)
.SelectMany(group => group.OrderByVersion().Take(1))
.OrderByVersion()
.GetTrustedCertificates(cancellationToken)
.FirstOrDefault();

if (certificate is null)
{
logger.LogDebug("No trusted ASP.NET Core development certificate was available to export");
return null;
}

logger.LogDebug(
"Selected ASP.NET Core development certificate {Thumbprint} for public PEM export",
certificate.Thumbprint);

return GetOrCreateCertificateCacheFile(certificate, outputDirectory);
}
finally
{
CertificateManager.DisposeCertificates(availableCertificates);
}
}

internal string GetOrCreateCertificateCacheFile(X509Certificate2 certificate, string outputDirectory)
{
var pemContents = Encoding.UTF8.GetBytes(certificate.ExportCertificatePem());
var hash = Convert.ToHexString(XxHash128.Hash(pemContents)).ToLowerInvariant();
var outputPath = Path.Combine(outputDirectory, $"aspire-dev-cert-{hash}.pem");

if (File.Exists(outputPath))
{
logger.LogDebug("Reusing cached development certificate PEM at {Path}", outputPath);
return outputPath;
}

logger.LogDebug("Writing development certificate PEM to cache at {Path}", outputPath);
CertificateCacheWriter.WriteFile(outputPath, pemContents, logger);
return outputPath;
}

private static string[]? GetSanExtension(X509Certificate2 cert)
{
var dnsNames = new List<string>();
Expand Down
Loading
Loading