Skip to content

Pre-export dev certificate to Aspire cache to avoid macOS keychain prompts#16282

Merged
danegsta merged 12 commits into
microsoft:mainfrom
danegsta:dev/dnegstad/pre-export-dev-cert-cache
Apr 21, 2026
Merged

Pre-export dev certificate to Aspire cache to avoid macOS keychain prompts#16282
danegsta merged 12 commits into
microsoft:mainfrom
danegsta:dev/dnegstad/pre-export-dev-cert-cache

Conversation

@danegsta
Copy link
Copy Markdown
Member

Description

On macOS, running an Aspire app host after trusting the developer certificate triggers Keychain access prompts to export the certificate's private key material. This happens because the Aspire hosting layer needs both PFX and PEM key formats, and each access to the keychain-backed certificate triggers a separate prompt.

This PR eliminates those prompts by:

  1. CLI side (MacOSCertificateManager): When creating, correcting, or trusting a developer certificate, also write the key material to the Aspire hosting dev-cert cache (~/.aspire/dev-certs/https/) using the in-memory certificate — no keychain access needed. All cache writes use ExportCertificate consistently.

  2. Hosting side (DeveloperCertificateService): Restructured GetKeyMaterialAsync to try cache reads for both PEM and PFX first, then do a single private key access for any misses. This reduces two keychain prompts to at most one on cache miss.

  3. CLI trust flow: Added TrustCertificateAsync to ICertificateService so both aspire run and aspire certs trust share the same trust + cache-population flow. Includes a PreExportKeyMaterialAsync fallback for certificates created before the cache writes existed.

Validation

  • Built and ran all 2115 CLI tests (all pass)
  • Manually tested on macOS: aspire certs trust followed by aspire run no longer triggers keychain export prompts

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
    • No
  • Does the change require an update in our Aspire docs?
    • Yes
    • No

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>
Copilot AI review requested due to automatic review settings April 17, 2026 21:07
@danegsta danegsta requested a review from karolz-ms as a code owner April 17, 2026 21:07
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 17, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 16282

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 16282"

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Reduces/avoids macOS Keychain prompts when Aspire needs developer certificate private key material by proactively caching/exporting key material on the CLI side and preferring cache reads on the hosting side.

Changes:

  • Adds a CLI-level trust flow (ICertificateService.TrustCertificateAsync) so aspire certs trust and aspire run share trust + cache-priming behavior.
  • Adds a macOS fallback pre-export (ICertificateToolRunner.PreExportKeyMaterialAsync) that populates ~/.aspire/dev-certs/https/ from the on-disk dev-certs PFX.
  • Restructures DeveloperCertificateService.GetKeyMaterialAsync to read cached PEM/PFX first and only fall back to certificate export when needed, then writes back to cache.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs Extends test tool runner with a pre-export callback to validate new behavior.
tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs Updates test certificate service to implement the new trust API.
tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs Updates embedded test ICertificateService implementation for the new trust API.
tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs Updates throwing test certificate service to include TrustCertificateAsync.
tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs Updates throwing test certificate service to include TrustCertificateAsync.
tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs Adds coverage asserting pre-export is invoked in trust and ensure-trusted flows.
src/Aspire.Hosting/DeveloperCertificateService.cs Cache-first reads for PEM/PFX with single-pass export fallback and cache write-back.
src/Aspire.Cli/Commands/CertificatesTrustCommand.cs Switches trust command to use ICertificateService.TrustCertificateAsync.
src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs Implements best-effort pre-export on macOS to prime Aspire’s dev-cert cache.
src/Aspire.Cli/Certificates/ICertificateToolRunner.cs Adds pre-export API to tool runner abstraction.
src/Aspire.Cli/Certificates/CertificateService.cs Introduces TrustCertificateAsync and integrates pre-export into ensure-trusted flow.
src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs Exports key material into Aspire cache during trust/correct/save operations.

Comment thread src/Aspire.Hosting/DeveloperCertificateService.cs Outdated
Comment thread src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs Outdated
Comment thread src/Aspire.Cli/Certificates/CertificateService.cs Outdated
Comment thread src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs Outdated
Comment thread src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs Outdated
@JamesNK
Copy link
Copy Markdown
Member

JamesNK commented Apr 18, 2026

I feel like there should be some logging around the cert cache. It can be at a low level (trace)

Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

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

Reviewed the PR for correctness and found 4 issues:\n\n- 2 bugs (multiple keychain prompt regressions): CorrectCertificateState triggers 3 keychain prompts instead of 1; ExportKeyMaterial makes 2 private-key accesses instead of the intended 1\n- 1 correctness issue: TrustCertificateAsync calls PreExportKeyMaterialAsync unconditionally even after trust failure/cancellation\n- 1 minor issue: PreExportKeyMaterialAsync ignores the CancellationToken parameter

Comment thread src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs Outdated
Comment thread src/Aspire.Hosting/DeveloperCertificateService.cs Outdated
Comment thread src/Aspire.Cli/Certificates/CertificateService.cs Outdated
Comment thread src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs Outdated
Copy link
Copy Markdown
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

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

Reviewed the full diff. The approach is sound — pre-populating the Aspire cache from the CLI side at trust/create/correct time so hosting finds cached key material instead of hitting the Keychain is clean and the fallback behavior on cache miss is unchanged.

Key observations:

  • File extension alignment between CLI writes (.pfx, .key via ExportCertificate's auto-generated .key from .pem path) and hosting reads (.pfx, .key) is correct.
  • Cache key computation (SHA256(thumbprint) as hex) matches between GetAspireCertificateHash and the hosting-side password-less lookup in GetKeyMaterialAsync.
  • SaveCertificateCore calling ExportCertificate 3x is safe — the cert is still in-memory (not keychain-backed) at that point since SaveCertificateToUserKeychain creates a separate copy in the keychain.
  • CorrectCertificateState correctly loads from the just-written on-disk PFX to avoid a second keychain prompt.
  • The pre-trust cache write in TrustCertificateCore is harmless if trust fails — hosting checks trust independently.

Test coverage note: The two new TrustCertificateAsync tests cover the service-layer wiring (cancel + failure paths), but the core cache logic (WriteAspireCacheFromDiskPfx, GetAspireCertificateHash, TryReadCachedPfx, TryReadCachedKeyPem, ExportKeyMaterial, WriteCacheFilesAsync) has no unit test coverage. An integration-style test verifying the cache round-trip would add confidence.

Agree with @JamesNK's open comment on ExportKeyMaterial — when both PEM key and PFX are needed on cache miss, there are still two separate private key accesses.

@danegsta danegsta force-pushed the dev/dnegstad/pre-export-dev-cert-cache branch from 1208d20 to f4009c1 Compare April 20, 2026 18:28
@danegsta danegsta force-pushed the dev/dnegstad/pre-export-dev-cert-cache branch from f4009c1 to 2a7acc9 Compare April 20, 2026 19:56
@danegsta
Copy link
Copy Markdown
Member Author

@eerhardt we can do SOME testing of certificates, but realistically we can only validate on Linux CI machines; Mac and Windows CI don't support the full set of required certificate operations.

danegsta and others added 8 commits April 20, 2026 13:59
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
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>
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>
@github-actions
Copy link
Copy Markdown
Contributor

Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

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

Review of certificate caching changes: 1 potential bug (non-RSA cert regression — uncertain severity), 1 behavioral concern (trust command in non-interactive mode), 1 minor allocation nit.

Comment thread src/Aspire.Hosting/DeveloperCertificateService.cs
Comment thread src/Aspire.Cli/Commands/CertificatesTrustCommand.cs
Comment thread src/Aspire.Hosting/DeveloperCertificateService.cs
…rivateKey

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants