Browse Source

macOS dev-certs overhaul (#42251)

Aditya Mandaleeka 3 years ago
parent
commit
0b60b668f3

+ 3 - 11
src/Servers/Kestrel/Core/src/KestrelServerOptions.cs

@@ -285,18 +285,10 @@ public class KestrelServerOptions
                         Debug.Assert(status.FailureMessage != null, "Status with a failure result must have a message.");
                         logger.DeveloperCertificateFirstRun(status.FailureMessage);
 
-                        // Now that we've displayed a warning in the logs so that the user gets a notification that a prompt might appear, try
-                        // and access the certificate key, which might trigger a prompt.
-                        status = CertificateManager.Instance.CheckCertificateState(DefaultCertificate, interactive: true);
-                        if (!status.Success)
-                        {
-                            logger.BadDeveloperCertificateState();
-                        }
+                        // Prevent binding to HTTPS if the certificate is not valid (avoid the prompt)
+                        DefaultCertificate = null;
                     }
-
-                    logger.LocatedDevelopmentCertificate(DefaultCertificate);
-
-                    if (!CertificateManager.Instance.IsTrusted(DefaultCertificate))
+                    else if (!CertificateManager.Instance.IsTrusted(DefaultCertificate))
                     {
                         logger.DeveloperCertificateNotTrusted();
                     }

+ 55 - 22
src/Shared/CertificateGeneration/CertificateManager.cs

@@ -1,12 +1,9 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-using System;
-using System.Collections.Generic;
 using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
 using System.Diagnostics.Tracing;
-using System.IO;
 using System.Linq;
 using System.Security.Cryptography;
 using System.Security.Cryptography.X509Certificates;
@@ -19,6 +16,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation;
 internal abstract class CertificateManager
 {
     internal const int CurrentAspNetCoreCertificateVersion = 2;
+
+    // OID used for HTTPS certs
     internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1";
     internal const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate";
 
@@ -78,7 +77,7 @@ internal abstract class CertificateManager
         {
             using var store = new X509Store(storeName, location);
             store.Open(OpenFlags.ReadOnly);
-            certificates.AddRange(store.Certificates.OfType<X509Certificate2>());
+            PopulateCertificatesFromStore(store, certificates);
             IEnumerable<X509Certificate2> matchingCertificates = certificates;
             matchingCertificates = matchingCertificates
                 .Where(c => HasOid(c, AspNetHttpsOid));
@@ -161,6 +160,11 @@ internal abstract class CertificateManager
             GetCertificateVersion(certificate) >= AspNetHttpsCertificateVersion;
     }
 
+    protected virtual void PopulateCertificatesFromStore(X509Store store, List<X509Certificate2> certificates)
+    {
+        certificates.AddRange(store.Certificates.OfType<X509Certificate2>());
+    }
+
     public IList<X509Certificate2> GetHttpsCertificates() =>
         ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true);
 
@@ -340,6 +344,15 @@ internal abstract class CertificateManager
                 result = EnsureCertificateResult.FailedToTrustTheCertificate;
                 return result;
             }
+
+            if (result == EnsureCertificateResult.ValidCertificatePresent)
+            {
+                result = EnsureCertificateResult.ExistingHttpsCertificateTrusted;
+            }
+            else
+            {
+                result = EnsureCertificateResult.NewHttpsCertificateTrusted;
+            }
         }
 
         DisposeCertificates(!isNewCertificate ? certificates : certificates.Append(certificate));
@@ -411,13 +424,6 @@ internal abstract class CertificateManager
 
     public void CleanupHttpsCertificates()
     {
-        // On OS X we don't have a good way to manage trusted certificates in the system keychain
-        // so we do everything by invoking the native toolchain.
-        // This has some limitations, like for example not being able to identify our custom OID extension. For that
-        // matter, when we are cleaning up certificates on the machine, we start by removing the trusted certificates.
-        // To do this, we list the certificates that we can identify on the current user personal store and we invoke
-        // the native toolchain to remove them from the sytem keychain. Once we have removed the trusted certificates,
-        // we remove the certificates from the local user store to finish up the cleanup.
         var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false);
         var filteredCertificates = certificates.Where(c => c.Subject == Subject);
 
@@ -430,6 +436,8 @@ internal abstract class CertificateManager
 
         foreach (var certificate in filteredCertificates)
         {
+            // RemoveLocations.All will first remove from the trusted roots (e.g. keychain on
+            // macOS) and then from the local user store.
             RemoveCertificate(certificate, RemoveLocations.All);
         }
     }
@@ -745,7 +753,7 @@ internal abstract class CertificateManager
         }
     }
 
-    private static void RemoveCertificateFromUserStore(X509Certificate2 certificate)
+    protected virtual void RemoveCertificateFromUserStore(X509Certificate2 certificate)
     {
         try
         {
@@ -753,14 +761,7 @@ internal abstract class CertificateManager
             {
                 Log.RemoveCertificateFromUserStoreStart(GetDescription(certificate));
             }
-            using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
-            store.Open(OpenFlags.ReadWrite);
-            var matching = store.Certificates
-                .OfType<X509Certificate2>()
-                .Single(c => c.SerialNumber == certificate.SerialNumber);
-
-            store.Remove(matching);
-            store.Close();
+            RemoveCertificateFromUserStoreCore(certificate);
             Log.RemoveCertificateFromUserStoreEnd();
         }
         catch (Exception ex) when (Log.IsEnabled())
@@ -770,6 +771,17 @@ internal abstract class CertificateManager
         }
     }
 
+    protected virtual void RemoveCertificateFromUserStoreCore(X509Certificate2 certificate)
+    {
+        using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+        store.Open(OpenFlags.ReadWrite);
+        var matching = store.Certificates
+            .OfType<X509Certificate2>()
+            .Single(c => c.SerialNumber == certificate.SerialNumber);
+
+        store.Remove(matching);
+    }
+
     internal static string ToCertificateDescription(IEnumerable<X509Certificate2> certificates)
     {
         var list = certificates.ToList();
@@ -944,8 +956,8 @@ internal abstract class CertificateManager
         [Event(55, Level = EventLevel.Verbose, Message = "Finished importing the certificate to the keychain.")]
         internal void MacOSAddCertificateToKeyChainEnd() => WriteEvent(55);
 
-        [Event(56, Level = EventLevel.Error, Message = "An error has occurred while importing the certificate to the keychain: {0}.")]
-        internal void MacOSAddCertificateToKeyChainError(int exitCode) => WriteEvent(56, exitCode);
+        [Event(56, Level = EventLevel.Error, Message = "An error has occurred while importing the certificate to the keychain: {0}, {1}")]
+        internal void MacOSAddCertificateToKeyChainError(int exitCode, string output) => WriteEvent(56, exitCode, output);
 
         [Event(57, Level = EventLevel.Verbose, Message = "Writing the certificate to: {0}.")]
         public void WritePemKeyToDisk(string path) => WriteEvent(57, path);
@@ -970,6 +982,27 @@ internal abstract class CertificateManager
 
         [Event(64, Level = EventLevel.Error, Message = "The provided certificate '{0}' is not a valid ASP.NET Core HTTPS development certificate.")]
         internal void NoHttpsDevelopmentCertificate(string description) => WriteEvent(64, description);
+
+        [Event(65, Level = EventLevel.Verbose, Message = "The certificate is already trusted.")]
+        public void MacOSCertificateAlreadyTrusted() => WriteEvent(65);
+
+        [Event(66, Level = EventLevel.Verbose, Message = "Saving the certificate {1} to the user profile folder '{0}'.")]
+        internal void MacOSAddCertificateToUserProfileDirStart(string directory, string certificate) => WriteEvent(66, directory, certificate);
+
+        [Event(67, Level = EventLevel.Verbose, Message = "Finished saving the certificate to the user profile folder.")]
+        internal void MacOSAddCertificateToUserProfileDirEnd() => WriteEvent(67);
+
+        [Event(68, Level = EventLevel.Error, Message = "An error has occurred while saving certificate '{0}' in the user profile folder: {1}.")]
+        internal void MacOSAddCertificateToUserProfileDirError(string certificateThumbprint, string errorMessage) => WriteEvent(68, certificateThumbprint, errorMessage);
+
+        [Event(69, Level = EventLevel.Error, Message = "An error has occurred while removing certificate '{0}' from the user profile folder: {1}.")]
+        internal void MacOSRemoveCertificateFromUserProfileDirError(string certificateThumbprint, string errorMessage) => WriteEvent(69, certificateThumbprint, errorMessage);
+
+        [Event(70, Level = EventLevel.Error, Message = "The file '{0}' is not a valid certificate.")]
+        internal void MacOSFileIsNotAValidCertificate(string path) => WriteEvent(70, path);
+
+        [Event(71, Level = EventLevel.Warning, Message = "The on-disk store directory was not found.")]
+        internal void MacOSDiskStoreDoesNotExist() => WriteEvent(71);
     }
 
     internal sealed class UserCancelledTrustException : Exception

+ 2 - 0
src/Shared/CertificateGeneration/EnsureCertificateResult.cs

@@ -13,5 +13,7 @@ internal enum EnsureCertificateResult
     FailedToTrustTheCertificate,
     UserCancelledTrustStep,
     FailedToMakeKeyAccessible,
+    ExistingHttpsCertificateTrusted,
+    NewHttpsCertificateTrusted
 }
 

+ 262 - 116
src/Shared/CertificateGeneration/MacOSCertificateManager.cs

@@ -1,11 +1,9 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-using System;
-using System.Collections.Generic;
 using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
 using System.Globalization;
-using System.IO;
 using System.Linq;
 using System.Security.Cryptography;
 using System.Security.Cryptography.X509Certificates;
@@ -15,24 +13,54 @@ namespace Microsoft.AspNetCore.Certificates.Generation;
 
 internal sealed class MacOSCertificateManager : CertificateManager
 {
-    private const string CertificateSubjectRegex = "CN=(.*[^,]+).*";
-    private static readonly string MacOSUserKeyChain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db";
-    private const string MacOSSystemKeyChain = "/Library/Keychains/System.keychain";
-    private const string MacOSFindCertificateCommandLine = "security";
-    private const string MacOSFindCertificateCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p " + MacOSSystemKeyChain;
-    private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)";
-    private const string MacOSRemoveCertificateTrustCommandLine = "sudo";
-    private const string MacOSRemoveCertificateTrustCommandLineArgumentsFormat = "security remove-trusted-cert -d {0}";
+    private static readonly string MacOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db";
+
+    // System keychain. We no longer store certificates or create trust rules in the system
+    // keychain, but check for their presence here so that we can clean up state left behind
+    // by pre-.NET 7 versions of this tool.
+    private const string MacOSSystemKeychain = "/Library/Keychains/System.keychain";
+
+    // Well-known location on disk where dev-certs are stored.
+    private static readonly string MacOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https");
+
+    // Verify the certificate {0} for the SSL and X.509 Basic Policy.
+    private const string MacOSVerifyCertificateCommandLine = "security";
+    private const string MacOSVerifyCertificateCommandLineArgumentsFormat = $"verify-cert -c {{0}} -p basic -p ssl";
+
+    // Delete a certificate with the specified SHA-256 (or SHA-1) hash {0} from keychain {1}.
     private const string MacOSDeleteCertificateCommandLine = "sudo";
     private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} {1}";
-    private const string MacOSTrustCertificateCommandLine = "sudo";
-    private const string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " ";
 
+    // Add a certificate to the per-user trust settings in the user keychain. The trust policy
+    // for the certificate will be set to be always trusted for SSL and X.509 Basic Policy.
+    // Note: This operation will require user authentication.
+    private const string MacOSTrustCertificateCommandLine = "security";
+    private static readonly string MacOSTrustCertificateCommandLineArguments = $"add-trusted-cert -p basic -p ssl -k {MacOSUserKeychain} ";
+
+    // Import a pkcs12 certificate into the user keychain using the unwrapping passphrase {1}, and
+    // allow any application to access the imported key without warning.
     private const string MacOSAddCertificateToKeyChainCommandLine = "security";
-    private static readonly string MacOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import {0} -k " + MacOSUserKeyChain + " -t cert -f pkcs12 -P {1} -A";
+    private static readonly string MacOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import {0} -k " + MacOSUserKeychain + " -t cert -f pkcs12 -P {1} -A";
+
+    // Remove a certificate from the admin trust settings. We no longer add certificates to the
+    // admin trust settings, but need this for cleaning up certs generated by pre-.NET 7 versions
+    // of this tool that used to create trust settings in the system keychain.
+    // Note: This operation will require user authentication.
+    private const string MacOSUntrustLegacyCertificateCommandLine = "sudo";
+    private const string MacOSUntrustLegacyCertificateCommandLineArguments = "security remove-trusted-cert -d {0}";
+
+    // Find all matching certificates on the keychain {1} that have the name {0} and print
+    // print their SHA-256 and SHA-1 hashes.
+    private const string MacOSFindCertificateOnKeychainCommandLine = "security";
+    private const string MacOSFindCertificateOnKeychainCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p {1}";
+
+    // Format used by the tool when printing SHA-1 hashes.
+    private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)";
 
-    public const string InvalidCertificateState = "The ASP.NET Core developer certificate is in an invalid state. " +
-        "To fix this issue, run the following commands 'dotnet dev-certs https --clean' and 'dotnet dev-certs https' to remove all existing ASP.NET Core development certificates " +
+    public const string InvalidCertificateState =
+        "The ASP.NET Core developer certificate is in an invalid state. " +
+        "To fix this issue, run 'dotnet dev-certs https --clean' and 'dotnet dev-certs https' " +
+        "to remove all existing ASP.NET Core development certificates " +
         "and create a new untrusted developer certificate. " +
         "On macOS or Windows, use 'dotnet dev-certs https --trust' to trust the new certificate.";
 
@@ -41,8 +69,6 @@ internal sealed class MacOSCertificateManager : CertificateManager
         "A prompt might appear to ask for permission to access the key. " +
         "When that happens, select 'Always Allow' to grant 'dotnet' access to the certificate key in the future.";
 
-    private static readonly TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1);
-
     public MacOSCertificateManager()
     {
     }
@@ -54,6 +80,12 @@ internal sealed class MacOSCertificateManager : CertificateManager
 
     protected override void TrustCertificateCore(X509Certificate2 publicCertificate)
     {
+        if (IsTrusted(publicCertificate))
+        {
+            Log.MacOSCertificateAlreadyTrusted();
+            return;
+        }
+
         var tmpFile = Path.GetTempFileName();
         try
         {
@@ -77,10 +109,7 @@ internal sealed class MacOSCertificateManager : CertificateManager
         {
             try
             {
-                if (File.Exists(tmpFile))
-                {
-                    File.Delete(tmpFile);
-                }
+                File.Delete(tmpFile);
             }
             catch
             {
@@ -91,103 +120,75 @@ internal sealed class MacOSCertificateManager : CertificateManager
 
     internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive)
     {
-        var sentinelPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet", $"certificates.{candidate.GetCertHashString(HashAlgorithmName.SHA256)}.sentinel");
-        if (!interactive && !File.Exists(sentinelPath))
-        {
-            return new CheckCertificateStateResult(false, KeyNotAccessibleWithoutUserInteraction);
-        }
+        return File.Exists(GetCertificateFilePath(candidate)) ?
+            new CheckCertificateStateResult(true, null) :
+            new CheckCertificateStateResult(false, InvalidCertificateState);
+    }
 
-        // Tries to use the certificate key to validate it can't access it
+    internal override void CorrectCertificateState(X509Certificate2 candidate)
+    {
         try
         {
-            using var rsa = candidate.GetRSAPrivateKey();
-            if (rsa == null)
-            {
-                return new CheckCertificateStateResult(false, InvalidCertificateState);
-            }
-
-            // Encrypting a random value is the ultimate test for a key validity.
-            // Windows and Mac OS both return HasPrivateKey = true if there is (or there has been) a private key associated
-            // with the certificate at some point.
-            var value = new byte[32];
-            RandomNumberGenerator.Fill(value);
-            rsa.Decrypt(rsa.Encrypt(value, RSAEncryptionPadding.Pkcs1), RSAEncryptionPadding.Pkcs1);
+            // Ensure that the directory exists before writing to the file.
+            Directory.CreateDirectory(MacOSUserHttpsCertificateLocation);
 
-            // If we were able to access the key, create a sentinel so that we don't have to show a prompt
-            // on every kestrel run.
-            if (Directory.Exists(Path.GetDirectoryName(sentinelPath)) && !File.Exists(sentinelPath))
-            {
-                File.WriteAllText(sentinelPath, "true");
-            }
-
-            // Being able to encrypt and decrypt a payload is the strongest guarantee that the key is valid.
-            return new CheckCertificateStateResult(true, null);
-        }
-        catch (Exception)
-        {
-            return new CheckCertificateStateResult(false, InvalidCertificateState);
+            var certificatePath = GetCertificateFilePath(candidate);
+            ExportCertificate(candidate, certificatePath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
         }
-    }
-
-    internal override void CorrectCertificateState(X509Certificate2 candidate)
-    {
-        var status = CheckCertificateState(candidate, true);
-        if (!status.Success)
+        catch (Exception ex)
         {
-            throw new InvalidOperationException(InvalidCertificateState);
+            Log.MacOSAddCertificateToUserProfileDirError(candidate.Thumbprint, ex.Message);
         }
     }
 
+    // Use verify-cert to verify the certificate for the SSL and X.509 Basic Policy.
     public override bool IsTrusted(X509Certificate2 certificate)
     {
-        var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, MaxRegexTimeout);
-        if (!subjectMatch.Success)
+        var tmpFile = Path.GetTempFileName();
+        try
         {
-            throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'.");
+            ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem);
+
+            using var checkTrustProcess = Process.Start(new ProcessStartInfo(
+                MacOSVerifyCertificateCommandLine,
+                string.Format(CultureInfo.InvariantCulture, MacOSVerifyCertificateCommandLineArgumentsFormat, tmpFile))
+            {
+                RedirectStandardOutput = true,
+                // Do this to avoid showing output to the console when the cert is not trusted. It is trivial to export
+                // the cert and replicate the command to see details.
+                RedirectStandardError = true,
+            });
+            checkTrustProcess!.WaitForExit();
+            return checkTrustProcess.ExitCode == 0;
         }
-        var subject = subjectMatch.Groups[1].Value;
-        using var checkTrustProcess = Process.Start(new ProcessStartInfo(
-            MacOSFindCertificateCommandLine,
-            string.Format(CultureInfo.InvariantCulture, MacOSFindCertificateCommandLineArgumentsFormat, subject))
+        finally
         {
-            RedirectStandardOutput = true
-        });
-        var output = checkTrustProcess!.StandardOutput.ReadToEnd();
-        checkTrustProcess.WaitForExit();
-        var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, MaxRegexTimeout);
-        var hashes = matches.OfType<Match>().Select(m => m.Groups[1].Value).ToList();
-        return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal));
+            File.Delete(tmpFile);
+        }
     }
 
     protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
     {
-        if (IsTrusted(certificate)) // On OSX this check just ensures its on the system keychain
+        if (IsCertOnKeychain(MacOSSystemKeychain, certificate))
         {
-            // A trusted certificate in OSX is installed into the system keychain and
-            // as a "trust rule" applied to it.
-            // To remove the certificate we first need to remove the "trust rule" and then
-            // remove the certificate from the keychain.
-            // We don't care if we fail to remove the trust rule if
-            // for some reason the certificate became untrusted.
-            // Trying to remove the certificate from the keychain will fail if the certificate is
-            // trusted.
+            // Pre-.NET 7 versions of this tool used to store certs and trust settings on the
+            // system keychain. Check if that's the case for this cert, and if so, remove the
+            // trust rule and the cert from the system keychain.
             try
             {
-                RemoveCertificateTrustRule(certificate);
+                RemoveAdminTrustRule(certificate);
+                RemoveCertificateFromKeychain(MacOSSystemKeychain, certificate);
             }
             catch
             {
             }
-
-            RemoveCertificateFromKeyChain(MacOSSystemKeyChain, certificate);
-        }
-        else
-        {
-            Log.MacOSCertificateUntrusted(GetDescription(certificate));
         }
+
+        RemoveCertificateFromUserStoreCore(certificate);
     }
 
-    private static void RemoveCertificateTrustRule(X509Certificate2 certificate)
+    // Remove the certificate from the admin trust settings.
+    private static void RemoveAdminTrustRule(X509Certificate2 certificate)
     {
         Log.MacOSRemoveCertificateTrustRuleStart(GetDescription(certificate));
         var certificatePath = Path.GetTempFileName();
@@ -196,37 +197,37 @@ internal sealed class MacOSCertificateManager : CertificateManager
             var certBytes = certificate.Export(X509ContentType.Cert);
             File.WriteAllBytes(certificatePath, certBytes);
             var processInfo = new ProcessStartInfo(
-                MacOSRemoveCertificateTrustCommandLine,
+                MacOSUntrustLegacyCertificateCommandLine,
                 string.Format(
                     CultureInfo.InvariantCulture,
-                    MacOSRemoveCertificateTrustCommandLineArgumentsFormat,
+                    MacOSUntrustLegacyCertificateCommandLineArguments,
                     certificatePath
                 ));
+
             using var process = Process.Start(processInfo);
             process!.WaitForExit();
+
             if (process.ExitCode != 0)
             {
                 Log.MacOSRemoveCertificateTrustRuleError(process.ExitCode);
             }
+
             Log.MacOSRemoveCertificateTrustRuleEnd();
         }
         finally
         {
             try
             {
-                if (File.Exists(certificatePath))
-                {
-                    File.Delete(certificatePath);
-                }
+                File.Delete(certificatePath);
             }
             catch
             {
-                // We don't care about failing to do clean-up on a temp file.
+                // We don't care if we can't delete the temp file.
             }
         }
     }
 
-    private static void RemoveCertificateFromKeyChain(string keyChain, X509Certificate2 certificate)
+    private static void RemoveCertificateFromKeychain(string keychain, X509Certificate2 certificate)
     {
         var processInfo = new ProcessStartInfo(
             MacOSDeleteCertificateCommandLine,
@@ -234,7 +235,7 @@ internal sealed class MacOSCertificateManager : CertificateManager
                 CultureInfo.InvariantCulture,
                 MacOSDeleteCertificateCommandLineArgumentsFormat,
                 certificate.Thumbprint.ToUpperInvariant(),
-                keyChain
+                keychain
             ))
         {
             RedirectStandardOutput = true,
@@ -243,7 +244,7 @@ internal sealed class MacOSCertificateManager : CertificateManager
 
         if (Log.IsEnabled())
         {
-            Log.MacOSRemoveCertificateFromKeyChainStart(keyChain, GetDescription(certificate));
+            Log.MacOSRemoveCertificateFromKeyChainStart(keychain, GetDescription(certificate));
         }
 
         using (var process = Process.Start(processInfo))
@@ -263,12 +264,70 @@ internal sealed class MacOSCertificateManager : CertificateManager
         Log.MacOSRemoveCertificateFromKeyChainEnd();
     }
 
-    // We don't have a good way of checking on the underlying implementation if ti is exportable, so just return true.
+    private static bool IsCertOnKeychain(string keychain, X509Certificate2 certificate)
+    {
+        TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1);
+        const string CertificateSubjectRegex = "CN=(.*[^,]+).*";
+
+        var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, MaxRegexTimeout);
+        if (!subjectMatch.Success)
+        {
+            throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'.");
+        }
+
+        var subject = subjectMatch.Groups[1].Value;
+
+        // Run the find-certificate command, and look for the cert's hash in the output
+        using var findCertificateProcess = Process.Start(new ProcessStartInfo(
+            MacOSFindCertificateOnKeychainCommandLine,
+            string.Format(CultureInfo.InvariantCulture, MacOSFindCertificateOnKeychainCommandLineArgumentsFormat, subject, keychain))
+        {
+            RedirectStandardOutput = true
+        });
+
+        var output = findCertificateProcess!.StandardOutput.ReadToEnd();
+        findCertificateProcess.WaitForExit();
+
+        var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, MaxRegexTimeout);
+        var hashes = matches.OfType<Match>().Select(m => m.Groups[1].Value).ToList();
+
+        return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal));
+    }
+
+    // We don't have a good way of checking on the underlying implementation if it is exportable, so just return true.
     protected override bool IsExportable(X509Certificate2 c) => true;
 
     protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation)
     {
-        // security import https.pfx -k $loginKeyChain -t cert -f pkcs12 -P password -A;
+        SaveCertificateToUserKeychain(certificate);
+
+        try
+        {
+            var certBytes = certificate.Export(X509ContentType.Pfx);
+
+            if (Log.IsEnabled())
+            {
+                Log.MacOSAddCertificateToUserProfileDirStart(MacOSUserKeychain, GetDescription(certificate));
+            }
+
+            // Ensure that the directory exists before writing to the file.
+            Directory.CreateDirectory(MacOSUserHttpsCertificateLocation);
+
+            File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes);
+        }
+        catch (Exception ex)
+        {
+            Log.MacOSAddCertificateToUserProfileDirError(certificate.Thumbprint, ex.Message);
+        }
+
+        Log.MacOSAddCertificateToKeyChainEnd();
+        Log.MacOSAddCertificateToUserProfileDirEnd();
+
+        return certificate;
+    }
+
+    private static void SaveCertificateToUserKeychain(X509Certificate2 certificate)
+    {
         var passwordBytes = new byte[48];
         RandomNumberGenerator.Fill(passwordBytes.AsSpan()[0..35]);
         var password = Convert.ToBase64String(passwordBytes, 0, 36);
@@ -278,12 +337,7 @@ internal sealed class MacOSCertificateManager : CertificateManager
 
         var processInfo = new ProcessStartInfo(
             MacOSAddCertificateToKeyChainCommandLine,
-        string.Format(
-            CultureInfo.InvariantCulture,
-            MacOSAddCertificateToKeyChainCommandLineArgumentsFormat,
-            certificatePath,
-            password
-        ))
+            string.Format(CultureInfo.InvariantCulture, MacOSAddCertificateToKeyChainCommandLineArgumentsFormat, certificatePath, password))
         {
             RedirectStandardOutput = true,
             RedirectStandardError = true
@@ -291,7 +345,7 @@ internal sealed class MacOSCertificateManager : CertificateManager
 
         if (Log.IsEnabled())
         {
-            Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeyChain, GetDescription(certificate));
+            Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeychain, GetDescription(certificate));
         }
 
         using (var process = Process.Start(processInfo))
@@ -301,20 +355,112 @@ internal sealed class MacOSCertificateManager : CertificateManager
 
             if (process.ExitCode != 0)
             {
-                Log.MacOSAddCertificateToKeyChainError(process.ExitCode);
-                throw new InvalidOperationException($@"There was an error importing the certificate into the user key chain '{certificate.Thumbprint}'.
-
-{output}");
+                Log.MacOSAddCertificateToKeyChainError(process.ExitCode, output);
+                throw new InvalidOperationException("Failed to add the certificate to the keychain. Are you running in a non-interactive session perhaps?");
             }
         }
 
         Log.MacOSAddCertificateToKeyChainEnd();
-
-        return certificate;
     }
 
+    private static string GetCertificateFilePath(X509Certificate2 certificate) =>
+        Path.Combine(MacOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx");
+
     protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
     {
         return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false);
     }
+
+    protected override void PopulateCertificatesFromStore(X509Store store, List<X509Certificate2> certificates)
+    {
+        if (store.Name! == StoreName.My.ToString() && store.Location == store.Location && Directory.Exists(MacOSUserHttpsCertificateLocation))
+        {
+            var certsFromDisk = GetCertsFromDisk();
+
+            var certsFromStore = new List<X509Certificate2>();
+            base.PopulateCertificatesFromStore(store, certsFromStore);
+
+            // Certs created by pre-.NET 7.
+            var onlyOnKeychain = certsFromStore.Except(certsFromDisk, ThumbprintComparer.Instance);
+
+            // Certs created (or "upgraded") by .NET 7+.
+            // .NET 7+ installs the certificate on disk as well as on the user keychain (for backwards
+            // compatibility with pre-.NET 7).
+            var onDiskAndKeychain = certsFromDisk.Intersect(certsFromStore, ThumbprintComparer.Instance);
+
+            // The only times we can find a certificate on the keychain and a certificate on keychain+disk
+            // are when the certificate on disk and keychain has expired and a pre-.NET 7 SDK has been
+            // used to create a new certificate, or when a pre-.NET 7 certificate has expired and .NET 7+
+            // has been used to create a new certificate. In both cases, the caller filters the invalid
+            // certificates out, so only the valid certificate is selected.
+            certificates.AddRange(onlyOnKeychain);
+            certificates.AddRange(onDiskAndKeychain);
+        }
+        else
+        {
+            base.PopulateCertificatesFromStore(store, certificates);
+        }
+    }
+
+    private sealed class ThumbprintComparer : IEqualityComparer<X509Certificate2>
+    {
+        public static readonly IEqualityComparer<X509Certificate2> Instance = new ThumbprintComparer();
+
+#pragma warning disable CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes).
+        bool IEqualityComparer<X509Certificate2>.Equals(X509Certificate2 x, X509Certificate2 y) =>
+            EqualityComparer<string>.Default.Equals(x?.Thumbprint, y?.Thumbprint);
+#pragma warning restore CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes).
+
+        int IEqualityComparer<X509Certificate2>.GetHashCode([DisallowNull] X509Certificate2 obj) =>
+            EqualityComparer<string>.Default.GetHashCode(obj.Thumbprint);
+    }
+
+    private static ICollection<X509Certificate2> GetCertsFromDisk()
+    {
+        var certsFromDisk = new List<X509Certificate2>();
+        if (!Directory.Exists(MacOSUserHttpsCertificateLocation))
+        {
+            Log.MacOSDiskStoreDoesNotExist();
+        }
+        else
+        {
+            var certificateFiles = Directory.EnumerateFiles(MacOSUserHttpsCertificateLocation, "aspnetcore-localhost-*.pfx");
+            foreach (var file in certificateFiles)
+            {
+                try
+                {
+                    var certificate = new X509Certificate2(file);
+                    certsFromDisk.Add(certificate);
+                }
+                catch (Exception)
+                {
+                    Log.MacOSFileIsNotAValidCertificate(file);
+                    throw;
+                }
+            }
+        }
+
+        return certsFromDisk;
+    }
+
+    protected override void RemoveCertificateFromUserStoreCore(X509Certificate2 certificate)
+    {
+        try
+        {
+            var certificatePath = GetCertificateFilePath(certificate);
+            if (File.Exists(certificatePath))
+            {
+                File.Delete(certificatePath);
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.MacOSRemoveCertificateFromUserProfileDirError(certificate.Thumbprint, ex.Message);
+        }
+
+        if (IsCertOnKeychain(MacOSUserKeychain, certificate))
+        {
+            RemoveCertificateFromKeychain(MacOSUserKeychain, certificate);
+        }
+    }
 }

+ 4 - 4
src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs

@@ -25,7 +25,7 @@ public class CertificateManagerTests : IClassFixture<CertFixture>
         Output = output;
     }
 
-    public const string TestCertificateSubject = "CN=aspnet.test";
+    private const string TestCertificateSubject = "CN=aspnet.test";
 
     public ITestOutputHelper Output { get; }
 
@@ -94,7 +94,7 @@ public class CertificateManagerTests : IClassFixture<CertFixture>
             Assert.Contains(
                 httpsCertificate.Extensions.OfType<X509Extension>(),
                 e => e.Critical == false &&
-                    e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" &&
+                    e.Oid.Value == CertificateManager.AspNetHttpsOid &&
                     e.RawData[0] == _manager.AspNetHttpsCertificateVersion);
 
             Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString());
@@ -409,13 +409,13 @@ public class CertificateManagerTests : IClassFixture<CertFixture>
         Assert.Contains(
             firstCertificate.Extensions.OfType<X509Extension>(),
             e => e.Critical == false &&
-                e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" &&
+                e.Oid.Value == CertificateManager.AspNetHttpsOid &&
                 e.RawData[0] == 2);
 
         Assert.Contains(
             secondCertificate.Extensions.OfType<X509Extension>(),
             e => e.Critical == false &&
-                e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" &&
+                e.Oid.Value == CertificateManager.AspNetHttpsOid &&
                 e.RawData[0] == 1);
     }
 }

+ 15 - 10
src/Tools/dotnet-dev-certs/src/Program.cs

@@ -1,9 +1,6 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-using System;
-using System.Collections.Generic;
-using System.IO;
 using System.Linq;
 using System.Runtime.InteropServices;
 using System.Security.Cryptography.X509Certificates;
@@ -15,6 +12,8 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools;
 
 internal sealed class Program
 {
+    // NOTE: Exercise caution when touching these exit codes, since existing tooling
+    // might depend on some of these values.
     private const int CriticalError = -1;
     private const int Success = 0;
     private const int ErrorCreatingTheCertificate = 1;
@@ -74,8 +73,8 @@ internal sealed class Program
 
                 // We want to force generating a key without a password to not be an accident.
                 var noPassword = c.Option("-np|--no-password",
-                "Explicitly request that you don't use a password for the key when exporting a certificate to a PEM format",
-                CommandOptionType.NoValue);
+                    "Explicitly request that you don't use a password for the key when exporting a certificate to a PEM format",
+                    CommandOptionType.NoValue);
 
                 var check = c.Option(
                     "-c|--check",
@@ -170,10 +169,10 @@ internal sealed class Program
 
                     if (clean.HasValue())
                     {
-                        var clean = CleanHttpsCertificates(reporter);
-                        if (clean != Success || !import.HasValue())
+                        var cleanResult = CleanHttpsCertificates(reporter);
+                        if (cleanResult != Success || !import.HasValue())
                         {
-                            return clean;
+                            return cleanResult;
                         }
 
                         return ImportCertificate(import, password, reporter);
@@ -365,9 +364,9 @@ internal sealed class Program
             {
                 reporter.Warn("Trusting the HTTPS development certificate was requested. If the certificate is not " +
                     "already trusted we will run the following command:" + Environment.NewLine +
-                    "'sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain <<certificate>>'" +
+                    "'security add-trusted-cert -p basic -p ssl -k <<login-keychain>> <<certificate>>'" +
                     Environment.NewLine + "This command might prompt you for your password to install the certificate " +
-                    "on the system keychain. To undo these changes: 'sudo security remove-trusted-cert -d <<certificate>>'");
+                    "on the keychain. To undo these changes: 'security remove-trusted-cert <<certificate>>'" + Environment.NewLine);
             }
 
             if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
@@ -430,6 +429,12 @@ internal sealed class Program
             case EnsureCertificateResult.UserCancelledTrustStep:
                 reporter.Warn("The user cancelled the trust step.");
                 return ErrorUserCancelledTrustPrompt;
+            case EnsureCertificateResult.ExistingHttpsCertificateTrusted:
+                reporter.Output("Successfully trusted the existing HTTPS certificate.");
+                return Success;
+            case EnsureCertificateResult.NewHttpsCertificateTrusted:
+                reporter.Output("Successfully created and trusted a new HTTPS certificate.");
+                return Success;
             default:
                 reporter.Error("Something went wrong. The HTTPS developer certificate could not be created.");
                 return CriticalError;

+ 4 - 0
src/Tools/dotnet-dev-certs/src/dotnet-dev-certs.csproj

@@ -18,4 +18,8 @@
     <Compile Include="$(ToolSharedSourceRoot)CommandLine\**\*.cs" />
   </ItemGroup>
 
+  <ItemGroup>
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests" />
+  </ItemGroup>
+
 </Project>