Skip to content

Commit 52e6877

Browse files
danegstaCopilot
andauthored
Pre-export dev certificate to Aspire cache to avoid macOS keychain prompts (#16282)
* Pre-export dev certificate to Aspire cache to avoid keychain prompts When the Aspire CLI generates, corrects, or trusts a developer certificate on macOS, also write the PFX and PEM key material to the Aspire hosting dev-cert cache (~/.aspire/dev-certs/https/). This lets app-host processes load key material from disk instead of triggering macOS Keychain access prompts. Changes: - MacOSCertificateManager: SaveCertificateCore, CorrectCertificateState, and TrustCertificateCore now write both the .aspnet and .aspire caches using ExportCertificate consistently. - DeveloperCertificateService: Restructured GetKeyMaterialAsync to try cache reads first, then do a single private key access for any misses (reduces two keychain prompts to one on cache miss). - CertificateService: Added TrustCertificateAsync with PreExportKeyMaterialAsync fallback for certs created before the cache writes existed. - CertificatesTrustCommand: Refactored to use ICertificateService.TrustCertificateAsync. - Added tests for trust flow and pre-export behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update to use pkcs12 file in both places, removed unused cert export method * Fix macOS dev cert cache prewarming Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove Hosting dependency from CLI cert test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Unify certificate trust path in CLI Collapse EnsureCertificatesTrustedAsync and TrustCertificateAsync into a single path used by the apphost, init/template, and 'aspire certs trust' callers. The service always runs the trust operation so the Aspire cache stays populated even when the certificate is already trusted, and it emits the same TrustCancelled / CertificatesMayNotBeFullyTrusted warnings consistently across all callers. SSL_CERT_DIR handling on Linux partial trust is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Warm Aspire cert cache even when .aspnet PFX missing WriteAspireCacheFromDiskPfx previously no-op'd when the .aspnet PFX did not already exist on disk, which meant the Aspire cache would not be warmed if the .aspnet cache hadn't already been written during trust. Export the .aspnet PFX on demand in that case so both caches are always populated together. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Cleanup files that were changed for no reason * Cleanup an additional file * Cleanup two more files with unnecessary edits * Skip interactive cert trust in non-interactive mode In non-interactive environments on macOS and Windows we can't successfully prompt for certificate trust (Keychain password / trust dialog) and we don't want to silently generate an untrusted certificate. Inject ICliHostEnvironment into CertificateService and, when SupportsInteractiveInput is false on non-Linux, skip TrustHttpCertificate but still run CheckHttpCertificate so we can warn with distinct messages for partially trusted and not trusted states. Linux trust is non-interactive so the full flow is still run there. Fixes AspireCliTsStarterSmoke hanging on 'Trusting certificates...' in Windows CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Support ECDSA private keys in DeveloperCertificateService.ExportFromPrivateKey ExportFromPrivateKey silently returned null for non-RSA certificates, meaning a user-supplied ECDSA certificate would skip cache warming without any diagnostic. Fall back to GetECDsaPrivateKey when the cert has no RSA key and throw InvalidOperationException when neither is available. ExportKeyPem now operates against AsymmetricAlgorithm and picks the right temporary key type when re-exporting unencrypted PKCS#8. Added tests covering the ECDSA path (password and no-password variants), the public-only-certificate failure case, and an RSA sanity check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b94ddc9 commit 52e6877

30 files changed

Lines changed: 794 additions & 533 deletions

src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Globalization;
77
using System.Security.Cryptography;
88
using System.Security.Cryptography.X509Certificates;
9+
using System.Text;
910
using System.Text.RegularExpressions;
1011
using Microsoft.Extensions.Logging;
1112

@@ -32,6 +33,10 @@ internal sealed class MacOSCertificateManager : CertificateManager
3233
// Well-known location on disk where dev-certs are stored.
3334
private static readonly string s_macOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https");
3435

36+
// Well-known location where Aspire.Hosting caches dev-cert key material to avoid
37+
// triggering macOS Keychain access prompts at app-host startup time.
38+
private static readonly string s_aspireDevCertsCacheDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire", "dev-certs", "https");
39+
3540
// Verify the certificate {0} for the SSL and X.509 Basic Policy.
3641
private const string MacOSVerifyCertificateCommandLine = "security";
3742
private const string MacOSVerifyCertificateCommandLineArgumentsFormat = "verify-cert -c \"{0}\" -p basic -p ssl";
@@ -84,6 +89,10 @@ internal MacOSCertificateManager(string subject, int version)
8489

8590
protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertificate)
8691
{
92+
// Populate the Aspire cache even when the certificate is already trusted so
93+
// `aspire certs trust` can prewarm the cache without reapplying trust.
94+
WriteAspireCacheFromDiskPfx(GetCertificateFilePath(publicCertificate), publicCertificate);
95+
8796
var oldTrustLevel = GetTrustLevel(publicCertificate);
8897
if (oldTrustLevel != TrustLevel.None)
8998
{
@@ -97,6 +106,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertif
97106
{
98107
// We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key
99108
ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx);
109+
100110
if (Log.IsEnabled())
101111
{
102112
Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {s_macOSTrustCertificateCommandLineArguments}{tmpFile}");
@@ -111,7 +121,6 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertif
111121
}
112122
}
113123
Log.MacOSTrustCommandEnd();
114-
return TrustLevel.Full;
115124
}
116125
finally
117126
{
@@ -124,6 +133,8 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertif
124133
// We don't care if we can't delete the temp file.
125134
}
126135
}
136+
137+
return TrustLevel.Full;
127138
}
128139

129140
internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate)
@@ -137,9 +148,15 @@ internal override void CorrectCertificateState(X509Certificate2 candidate)
137148
{
138149
try
139150
{
140-
// This path is in a well-known folder, so we trust the permissions.
141-
var certificatePath = GetCertificateFilePath(candidate);
142-
ExportCertificate(candidate, certificatePath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
151+
var onDiskPfxPath = GetCertificateFilePath(candidate);
152+
153+
// The .aspnet PFX write needs to use the keychain-backed cert since
154+
// it's the authoritative source being corrected.
155+
ExportCertificate(candidate, onDiskPfxPath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
156+
157+
// For the Aspire cache, load from the on-disk PFX we just wrote to avoid
158+
// a second keychain access prompt.
159+
WriteAspireCacheFromDiskPfx(onDiskPfxPath, candidate);
143160
}
144161
catch (Exception ex)
145162
{
@@ -310,17 +327,15 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi
310327

311328
try
312329
{
313-
var certBytes = certificate.Export(X509ContentType.Pfx);
314-
315330
if (Log.IsEnabled())
316331
{
317332
Log.MacOSAddCertificateToUserProfileDirStart(s_macOSUserKeychain, GetDescription(certificate));
318333
}
319334

320-
// Ensure that the directory exists before writing to the file.
321-
CreateDirectoryWithPermissions(s_macOSUserHttpsCertificateLocation);
335+
ExportCertificate(certificate, GetCertificateFilePath(certificate), includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
322336

323-
File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes);
337+
var aspireLookup = GetAspireCertificateHash(certificate);
338+
ExportCertificate(certificate, Path.Combine(s_aspireDevCertsCacheDirectory, $"{aspireLookup}.pfx"), includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
324339
}
325340
catch (Exception ex)
326341
{
@@ -373,6 +388,60 @@ private void SaveCertificateToUserKeychain(X509Certificate2 certificate)
373388
private static string GetCertificateFilePath(X509Certificate2 certificate) =>
374389
Path.Combine(s_macOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx");
375390

391+
/// <summary>
392+
/// Writes Aspire hosting cache entries (PFX and PEM key) by loading the certificate from the
393+
/// on-disk PFX at <paramref name="onDiskPfxPath"/> to avoid triggering a macOS Keychain
394+
/// access prompt. If the on-disk PFX does not yet exist (for example, when correcting the
395+
/// state of a pre-.NET 7 keychain-only certificate), it is exported first so the Aspire
396+
/// cache can always be warmed alongside it. This is a best-effort operation; failures are
397+
/// silently ignored so the app host can fall back to caching at startup.
398+
/// </summary>
399+
private void WriteAspireCacheFromDiskPfx(string onDiskPfxPath, X509Certificate2 certificate)
400+
{
401+
try
402+
{
403+
if (!File.Exists(onDiskPfxPath))
404+
{
405+
// The .aspnet PFX is the preferred source because loading from it avoids a
406+
// second keychain prompt. If it's missing, export it first (this is a
407+
// private-key export from the keychain, so it may prompt once) so both the
408+
// .aspnet and Aspire caches are populated together.
409+
ExportCertificate(certificate, onDiskPfxPath, includePrivateKey: true, password: null, CertificateKeyExportFormat.Pfx);
410+
}
411+
412+
using var diskCert = X509CertificateLoader.LoadPkcs12FromFile(onDiskPfxPath, password: null, X509KeyStorageFlags.Exportable);
413+
var aspireLookup = GetAspireCertificateHash(certificate);
414+
415+
CreateDirectoryWithPermissions(s_aspireDevCertsCacheDirectory);
416+
417+
ExportCertificate(diskCert, Path.Combine(s_aspireDevCertsCacheDirectory, $"{aspireLookup}.pfx"), includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
418+
419+
// Write PEM key cache — must match the format produced by DeveloperCertificateService.ExportKeyPem
420+
using var key = diskCert.GetRSAPrivateKey();
421+
if (key is not null)
422+
{
423+
var keyBytes = key.ExportPkcs8PrivateKey();
424+
var pem = PemEncoding.Write("PRIVATE KEY", keyBytes);
425+
Array.Clear(keyBytes, 0, keyBytes.Length);
426+
427+
var keyPath = Path.Combine(s_aspireDevCertsCacheDirectory, $"{aspireLookup}.key");
428+
File.WriteAllBytes(keyPath, Encoding.UTF8.GetBytes(pem));
429+
Array.Clear(pem, 0, pem.Length);
430+
}
431+
}
432+
catch
433+
{
434+
// Best effort — the app host will fall back to accessing the keychain directly.
435+
}
436+
}
437+
438+
/// <summary>
439+
/// Computes the Aspire hosting cache key for a certificate, matching the convention
440+
/// used by <c>DeveloperCertificateService</c>: SHA256(thumbprint) as hex.
441+
/// </summary>
442+
private static string GetAspireCertificateHash(X509Certificate2 certificate) =>
443+
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(certificate.Thumbprint)));
444+
376445
protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
377446
{
378447
return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false);

src/Aspire.Cli/Certificates/CertificateService.cs

Lines changed: 64 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System.Diagnostics;
55
using System.Globalization;
6-
using System.Runtime.InteropServices;
76
using Aspire.Cli.Interaction;
87
using Aspire.Cli.Resources;
98
using Aspire.Cli.Telemetry;
@@ -22,6 +21,21 @@ internal sealed class EnsureCertificatesTrustedResult
2221
/// to ensure certificates are properly trusted.
2322
/// </summary>
2423
public required IDictionary<string, string> EnvironmentVariables { get; init; }
24+
25+
/// <summary>
26+
/// Gets whether the trust operation completed successfully.
27+
/// </summary>
28+
public required bool Success { get; init; }
29+
30+
/// <summary>
31+
/// Gets whether the operation was cancelled by the user.
32+
/// </summary>
33+
public bool WasCancelled { get; init; }
34+
35+
/// <summary>
36+
/// Gets the underlying result code from the certificate manager.
37+
/// </summary>
38+
public EnsureCertificateResult? ResultCode { get; init; }
2539
}
2640

2741
internal interface ICertificateService
@@ -33,100 +47,79 @@ internal sealed class CertificateService(
3347
ICertificateToolRunner certificateToolRunner,
3448
IInteractionService interactionService,
3549
AspireCliTelemetry telemetry,
36-
ICliHostEnvironment hostEnvironment,
37-
Func<bool>? isNonInteractiveTrustSupported = null) : ICertificateService
50+
ICliHostEnvironment hostEnvironment) : ICertificateService
3851
{
3952
private const string SslCertDirEnvVar = "SSL_CERT_DIR";
40-
private readonly Func<bool> _isNonInteractiveTrustSupported = isNonInteractiveTrustSupported ?? OperatingSystem.IsLinux;
4153

4254
public async Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(CancellationToken cancellationToken)
4355
{
4456
using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client);
4557

4658
var environmentVariables = new Dictionary<string, string>();
4759

48-
// Use the machine-readable check (available in .NET 10 SDK which is the minimum required)
49-
var trustResult = await CheckMachineReadableAsync();
50-
await HandleMachineReadableTrustAsync(trustResult, environmentVariables);
51-
52-
return new EnsureCertificatesTrustedResult
53-
{
54-
EnvironmentVariables = environmentVariables
55-
};
56-
}
57-
58-
private async Task<CertificateTrustResult> CheckMachineReadableAsync()
59-
{
60-
var result = await interactionService.ShowStatusAsync(
61-
InteractionServiceStrings.CheckingCertificates,
62-
() => Task.FromResult(certificateToolRunner.CheckHttpCertificate()),
63-
emoji: KnownEmojis.LockedWithKey);
64-
65-
return result;
66-
}
67-
68-
private async Task HandleMachineReadableTrustAsync(
69-
CertificateTrustResult trustResult,
70-
Dictionary<string, string> environmentVariables)
71-
{
72-
// If fully trusted, nothing more to do
73-
if (trustResult.IsFullyTrusted)
74-
{
75-
return;
76-
}
60+
// In non-interactive environments on macOS and Windows we can't successfully
61+
// prompt for trust (macOS Keychain password, Windows trust dialog) and we also
62+
// don't want to silently generate a new certificate that won't be trusted.
63+
// Skip the trust attempt but still check the current state so we can warn when
64+
// the environment does not already have a trusted certificate. Linux trust is
65+
// non-interactive so it's safe to run the full flow there.
66+
var canPerformTrust = hostEnvironment.SupportsInteractiveInput || OperatingSystem.IsLinux();
7767

78-
// If not trusted at all, run the trust operation
79-
if (trustResult.IsNotTrusted)
68+
if (!canPerformTrust)
8069
{
81-
if (!hostEnvironment.SupportsInteractiveInput && !_isNonInteractiveTrustSupported())
70+
var preCheck = certificateToolRunner.CheckHttpCertificate();
71+
if (preCheck.IsPartiallyTrusted)
8272
{
83-
// In non-interactive mode (e.g. CI), skip the trust operation on platforms
84-
// where it requires user interaction (macOS Keychain password prompt, Windows
85-
// certificate trust dialog). Linux trust is non-interactive, so it can proceed.
86-
if (!trustResult.HasCertificates)
87-
{
88-
var ensureResultCode = await interactionService.ShowStatusAsync(
89-
InteractionServiceStrings.CheckingCertificates,
90-
() => Task.FromResult(certificateToolRunner.EnsureHttpCertificateExists()),
91-
emoji: KnownEmojis.LockedWithKey);
92-
93-
if (!IsSuccessfulEnsureResult(ensureResultCode))
94-
{
95-
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, ensureResultCode));
96-
}
97-
}
98-
99-
return;
73+
interactionService.DisplayMessage(KnownEmojis.Warning, ErrorStrings.CertificatesPartiallyTrustedNonInteractive);
10074
}
101-
102-
var trustResultCode = await interactionService.ShowStatusAsync(
103-
InteractionServiceStrings.TrustingCertificates,
104-
() => Task.FromResult(certificateToolRunner.TrustHttpCertificate()),
105-
emoji: KnownEmojis.LockedWithKey);
106-
107-
if (trustResultCode == EnsureCertificateResult.UserCancelledTrustStep)
75+
else if (!preCheck.IsFullyTrusted)
10876
{
109-
interactionService.DisplayMessage(KnownEmojis.Warning, CertificatesCommandStrings.TrustCancelled);
77+
interactionService.DisplayMessage(KnownEmojis.Warning, ErrorStrings.CertificatesNotTrustedNonInteractive);
11078
}
111-
else if (!CertificateHelpers.IsSuccessfulTrustResult(trustResultCode))
79+
80+
if (preCheck.IsPartiallyTrusted && OperatingSystem.IsLinux())
11281
{
113-
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, trustResultCode));
82+
ConfigureSslCertDir(environmentVariables);
11483
}
11584

116-
// Re-check trust status after trust operation
117-
trustResult = certificateToolRunner.CheckHttpCertificate();
85+
return new EnsureCertificatesTrustedResult
86+
{
87+
EnvironmentVariables = environmentVariables,
88+
Success = true
89+
};
11890
}
11991

120-
// If partially trusted (either initially or after trust), configure SSL_CERT_DIR on Linux
121-
if (trustResult.IsPartiallyTrusted && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
92+
// Always run trust so the Aspire cache stays populated even when the certificate
93+
// is already trusted. Each platform's TrustCertificateCore short-circuits without
94+
// prompting when the certificate is already in the trust store.
95+
var trustResultCode = await interactionService.ShowStatusAsync(
96+
InteractionServiceStrings.TrustingCertificates,
97+
() => Task.FromResult(certificateToolRunner.TrustHttpCertificate()),
98+
emoji: KnownEmojis.LockedWithKey);
99+
100+
if (trustResultCode == EnsureCertificateResult.UserCancelledTrustStep)
101+
{
102+
interactionService.DisplayMessage(KnownEmojis.Warning, CertificatesCommandStrings.TrustCancelled);
103+
}
104+
else if (!CertificateHelpers.IsSuccessfulTrustResult(trustResultCode))
105+
{
106+
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, trustResultCode));
107+
}
108+
109+
var postTrustCheck = certificateToolRunner.CheckHttpCertificate();
110+
if (postTrustCheck.IsPartiallyTrusted && OperatingSystem.IsLinux())
122111
{
123112
ConfigureSslCertDir(environmentVariables);
124113
}
125-
}
126114

127-
private static bool IsSuccessfulEnsureResult(EnsureCertificateResult result) =>
128-
result is EnsureCertificateResult.Succeeded
129-
or EnsureCertificateResult.ValidCertificatePresent;
115+
return new EnsureCertificatesTrustedResult
116+
{
117+
EnvironmentVariables = environmentVariables,
118+
Success = CertificateHelpers.IsSuccessfulTrustResult(trustResultCode),
119+
WasCancelled = trustResultCode == EnsureCertificateResult.UserCancelledTrustStep,
120+
ResultCode = trustResultCode
121+
};
122+
}
130123

131124
private static void ConfigureSslCertDir(Dictionary<string, string> environmentVariables)
132125
{
@@ -158,7 +151,6 @@ private static void ConfigureSslCertDir(Dictionary<string, string> environmentVa
158151
environmentVariables[SslCertDirEnvVar] = string.Join(Path.PathSeparator, systemCertDirs);
159152
}
160153
}
161-
162154
}
163155

164156
internal sealed class CertificateServiceException(string message) : Exception(message)

src/Aspire.Cli/Certificates/ICertificateToolRunner.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@ internal interface ICertificateToolRunner
1515
/// </summary>
1616
CertificateTrustResult CheckHttpCertificate();
1717

18-
/// <summary>
19-
/// Ensures the HTTPS development certificate exists without trusting it.
20-
/// </summary>
21-
EnsureCertificateResult EnsureHttpCertificateExists();
22-
2318
/// <summary>
2419
/// Trusts the HTTPS development certificate, creating one if necessary.
2520
/// </summary>

src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,6 @@ public EnsureCertificateResult TrustHttpCertificate()
8686
trust: true);
8787
}
8888

89-
public EnsureCertificateResult EnsureHttpCertificateExists()
90-
{
91-
var now = DateTimeOffset.Now;
92-
return certificateManager.EnsureAspNetCoreHttpsDevelopmentCertificate(
93-
now,
94-
now.Add(TimeSpan.FromDays(365)),
95-
trust: false,
96-
isInteractive: false);
97-
}
98-
9989
internal EnsureCertificateResult TrustHttpCertificateOnLinux(IEnumerable<X509Certificate2> availableCertificates, DateTimeOffset now)
10090
{
10191
X509Certificate2? certificate = null;

0 commit comments

Comments
 (0)