Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ namespace Microsoft.AspNetCore.Certificates.Generation;
[SupportedOSPlatform("windows")]
internal sealed class WindowsCertificateManager : CertificateManager
{
/// Win32 ERROR_CANCELLED (0x4C7) encoded as an HRESULT (0x800704C7).
/// Thrown when the user dismisses the Windows certificate-store security dialog.
private const int UserCancelledHResult = unchecked((int)0x800704C7);
private const int UserCancelledErrorCode = 1223;

public WindowsCertificateManager(ILogger logger) : base(logger)
Expand Down Expand Up @@ -89,7 +92,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate)
store.Add(publicCertificate);
return TrustLevel.Full;
}
catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode)
catch (CryptographicException exception) when (exception.HResult == UserCancelledHResult || exception.HResult == UserCancelledErrorCode)
{
Log.WindowsCertificateTrustCanceled();
throw new UserCancelledTrustException();
Expand Down
82 changes: 82 additions & 0 deletions src/Aspire.Cli/Certificates/CertificateHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Certificates.Generation;

namespace Aspire.Cli.Certificates;

/// <summary>
/// Shared helper methods for certificate operations.
/// </summary>
internal static partial class CertificateHelpers
{
/// <summary>
/// Determines whether the specified <see cref="EnsureCertificateResult"/> represents a successful trust operation.
/// </summary>
/// <param name="result">The result to evaluate.</param>
/// <returns><see langword="true"/> if the result represents success; otherwise, <see langword="false"/>.</returns>
internal static bool IsSuccessfulTrustResult(EnsureCertificateResult result) =>
result is EnsureCertificateResult.Succeeded
or EnsureCertificateResult.ValidCertificatePresent
or EnsureCertificateResult.ExistingHttpsCertificateTrusted
or EnsureCertificateResult.NewHttpsCertificateTrusted;

/// <summary>
/// Tries to detect the OpenSSL directory by running 'openssl version -d'.
/// Parses the OPENSSLDIR value from the output (e.g. OPENSSLDIR: "/usr/lib/ssl").
/// </summary>
/// <param name="openSslDir">The detected OpenSSL directory path if successful.</param>
/// <returns><see langword="true"/> if the directory was detected; otherwise, <see langword="false"/>.</returns>
internal static bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir)
{
openSslDir = null;

try
{
var processInfo = new ProcessStartInfo("openssl", "version -d")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};

using var process = Process.Start(processInfo);
if (process is null)
{
return false;
}

var stdout = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(TimeSpan.FromSeconds(5)))
{
return false;
}

if (process.ExitCode != 0)
{
return false;
}

var match = OpenSslVersionRegex().Match(stdout);
if (!match.Success)
{
return false;
}

openSslDir = match.Groups[1].Value;
return true;
}
catch
{
// openssl may not be installed — silently fail
return false;
}
}

[GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")]
internal static partial Regex OpenSslVersionRegex();
}
141 changes: 25 additions & 116 deletions src/Aspire.Cli/Certificates/CertificateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Microsoft.AspNetCore.Certificates.Generation;

namespace Aspire.Cli.Certificates;

Expand All @@ -31,7 +28,7 @@ internal interface ICertificateService
Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(CancellationToken cancellationToken);
}

internal sealed partial class CertificateService(
internal sealed class CertificateService(
ICertificateToolRunner certificateToolRunner,
IInteractionService interactionService,
AspireCliTelemetry telemetry) : ICertificateService
Expand All @@ -58,47 +55,30 @@ public async Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsyn
using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client);

var environmentVariables = new Dictionary<string, string>();
var ensureCertificateCollector = new OutputCollector();

// Use the machine-readable check (available in .NET 10 SDK which is the minimum required)
var trustResult = await CheckMachineReadableAsync(ensureCertificateCollector, cancellationToken);
await HandleMachineReadableTrustAsync(trustResult, ensureCertificateCollector, environmentVariables, cancellationToken);
var trustResult = await CheckMachineReadableAsync();
await HandleMachineReadableTrustAsync(trustResult, environmentVariables);

return new EnsureCertificatesTrustedResult
{
EnvironmentVariables = environmentVariables
};
}

private async Task<CertificateTrustResult> CheckMachineReadableAsync(
OutputCollector collector,
CancellationToken cancellationToken)
private async Task<CertificateTrustResult> CheckMachineReadableAsync()
{
var options = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = collector.AppendOutput,
StandardErrorCallback = collector.AppendError,
};

var (_, result) = await interactionService.ShowStatusAsync(
var result = await interactionService.ShowStatusAsync(
InteractionServiceStrings.CheckingCertificates,
async () => await certificateToolRunner.CheckHttpCertificateMachineReadableAsync(options, cancellationToken),
() => Task.FromResult(certificateToolRunner.CheckHttpCertificate()),
emoji: KnownEmojis.LockedWithKey);

// Return the result or a default "no certificates" result
return result ?? new CertificateTrustResult
{
HasCertificates = false,
TrustLevel = null,
Certificates = []
};
return result;
}

private async Task HandleMachineReadableTrustAsync(
CertificateTrustResult trustResult,
OutputCollector collector,
Dictionary<string, string> environmentVariables,
CancellationToken cancellationToken)
Dictionary<string, string> environmentVariables)
{
// If fully trusted, nothing more to do
if (trustResult.IsFullyTrusted)
Expand All @@ -109,35 +89,22 @@ private async Task HandleMachineReadableTrustAsync(
// If not trusted at all, run the trust operation
if (trustResult.IsNotTrusted)
{
var options = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = collector.AppendOutput,
StandardErrorCallback = collector.AppendError,
};

var trustExitCode = await interactionService.ShowStatusAsync(
var trustResultCode = await interactionService.ShowStatusAsync(
InteractionServiceStrings.TrustingCertificates,
() => certificateToolRunner.TrustHttpCertificateAsync(options, cancellationToken),
() => Task.FromResult(certificateToolRunner.TrustHttpCertificate()),
emoji: KnownEmojis.LockedWithKey);

if (trustExitCode != 0)
if (trustResultCode == EnsureCertificateResult.UserCancelledTrustStep)
{
interactionService.DisplayLines(collector.GetLines());
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, trustExitCode));
interactionService.DisplayMessage(KnownEmojis.Warning, CertificatesCommandStrings.TrustCancelled);
}

// Re-check trust status after trust operation
var recheckOptions = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = collector.AppendOutput,
StandardErrorCallback = collector.AppendError,
};

var (_, recheckResult) = await certificateToolRunner.CheckHttpCertificateMachineReadableAsync(recheckOptions, cancellationToken);
if (recheckResult is not null)
else if (!CertificateHelpers.IsSuccessfulTrustResult(trustResultCode))
{
trustResult = recheckResult;
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, trustResultCode));
}

// Re-check trust status after trust operation
trustResult = certificateToolRunner.CheckHttpCertificate();
}

// If partially trusted (either initially or after trust), configure SSL_CERT_DIR on Linux
Expand Down Expand Up @@ -174,9 +141,13 @@ private static void ConfigureSslCertDir(Dictionary<string, string> environmentVa
// Query OpenSSL to get its configured certificate directory.
var systemCertDirs = new List<string>();

if (TryGetOpenSslCertsDirectory(out var openSslCertsDir))
if (CertificateHelpers.TryGetOpenSslDirectory(out var openSslDir))
{
systemCertDirs.Add(openSslCertsDir);
var openSslCertsDir = Path.Combine(openSslDir, "certs");
if (Directory.Exists(openSslCertsDir))
{
systemCertDirs.Add(openSslCertsDir);
}
}
else
{
Expand All @@ -198,70 +169,8 @@ private static void ConfigureSslCertDir(Dictionary<string, string> environmentVa
}
}

/// <summary>
/// Attempts to get the OpenSSL certificates directory by running 'openssl version -d'.
/// This is the same approach used by ASP.NET Core's certificate manager.
/// </summary>
/// <param name="certsDir">The path to the OpenSSL certificates directory if found.</param>
/// <returns>True if the OpenSSL certs directory was found, false otherwise.</returns>
private static bool TryGetOpenSslCertsDirectory([NotNullWhen(true)] out string? certsDir)
{
certsDir = null;

try
{
var processInfo = new ProcessStartInfo("openssl", "version -d")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};

using var process = Process.Start(processInfo);
if (process is null)
{
return false;
}

var stdout = process.StandardOutput.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(5));

if (process.ExitCode != 0)
{
return false;
}

// Parse output like: OPENSSLDIR: "/usr/lib/ssl"
var match = OpenSslVersionRegex().Match(stdout);
if (!match.Success)
{
return false;
}

var openSslDir = match.Groups[1].Value;
certsDir = Path.Combine(openSslDir, "certs");

// Verify the directory exists
if (!Directory.Exists(certsDir))
{
certsDir = null;
return false;
}

return true;
}
catch
{
return false;
}
}

[GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")]
private static partial Regex OpenSslVersionRegex();
}

public sealed class CertificateServiceException(string message) : Exception(message)
internal sealed class CertificateServiceException(string message) : Exception(message)
{

}
Loading
Loading