diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index 84e274202575..317edfdfd14a 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -285,18 +285,10 @@ private void EnsureDefaultCert() 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(); } diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index ec4c1db015ae..af927545cfcf 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/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 @@ public IList ListCertificates( { using var store = new X509Store(storeName, location); store.Open(OpenFlags.ReadOnly); - certificates.AddRange(store.Certificates.OfType()); + PopulateCertificatesFromStore(store, certificates); IEnumerable matchingCertificates = certificates; matchingCertificates = matchingCertificates .Where(c => HasOid(c, AspNetHttpsOid)); @@ -161,6 +160,11 @@ bool IsValidCertificate(X509Certificate2 certificate, DateTimeOffset currentDate GetCertificateVersion(certificate) >= AspNetHttpsCertificateVersion; } + protected virtual void PopulateCertificatesFromStore(X509Store store, List certificates) + { + certificates.AddRange(store.Certificates.OfType()); + } + public IList GetHttpsCertificates() => ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true); @@ -340,6 +344,15 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( 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 ImportCertificateResult ImportCertificate(string certificatePath, strin 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 @@ public void CleanupHttpsCertificates() 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 static void DisposeCertificates(IEnumerable disposabl } } - private static void RemoveCertificateFromUserStore(X509Certificate2 certificate) + protected virtual void RemoveCertificateFromUserStore(X509Certificate2 certificate) { try { @@ -753,14 +761,7 @@ private static void RemoveCertificateFromUserStore(X509Certificate2 certificate) { Log.RemoveCertificateFromUserStoreStart(GetDescription(certificate)); } - using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); - store.Open(OpenFlags.ReadWrite); - var matching = store.Certificates - .OfType() - .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 @@ private static void RemoveCertificateFromUserStore(X509Certificate2 certificate) } } + protected virtual void RemoveCertificateFromUserStoreCore(X509Certificate2 certificate) + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + var matching = store.Certificates + .OfType() + .Single(c => c.SerialNumber == certificate.SerialNumber); + + store.Remove(matching); + } + internal static string ToCertificateDescription(IEnumerable certificates) { var list = certificates.ToList(); @@ -944,8 +956,8 @@ public sealed class CertificateManagerEventSource : EventSource [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 @@ public sealed class CertificateManagerEventSource : EventSource [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 diff --git a/src/Shared/CertificateGeneration/EnsureCertificateResult.cs b/src/Shared/CertificateGeneration/EnsureCertificateResult.cs index eb1e3c0b0a66..842b84a3643d 100644 --- a/src/Shared/CertificateGeneration/EnsureCertificateResult.cs +++ b/src/Shared/CertificateGeneration/EnsureCertificateResult.cs @@ -13,5 +13,7 @@ internal enum EnsureCertificateResult FailedToTrustTheCertificate, UserCancelledTrustStep, FailedToMakeKeyAccessible, + ExistingHttpsCertificateTrusted, + NewHttpsCertificateTrusted } diff --git a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs index 28c971680b77..e8d5e106a172 100644 --- a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs +++ b/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 MacOSCertificateManager(string subject, int version) protected override void TrustCertificateCore(X509Certificate2 publicCertificate) { + if (IsTrusted(publicCertificate)) + { + Log.MacOSCertificateAlreadyTrusted(); + return; + } + var tmpFile = Path.GetTempFileName(); try { @@ -77,10 +109,7 @@ protected override void TrustCertificateCore(X509Certificate2 publicCertificate) { try { - if (File.Exists(tmpFile)) - { - File.Delete(tmpFile); - } + File.Delete(tmpFile); } catch { @@ -91,103 +120,75 @@ protected override void TrustCertificateCore(X509Certificate2 publicCertificate) 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().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 @@ private static void RemoveCertificateTrustRule(X509Certificate2 certificate) 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 @@ private static void RemoveCertificateFromKeyChain(string keyChain, X509Certifica CultureInfo.InvariantCulture, MacOSDeleteCertificateCommandLineArgumentsFormat, certificate.Thumbprint.ToUpperInvariant(), - keyChain + keychain )) { RedirectStandardOutput = true, @@ -243,7 +244,7 @@ private static void RemoveCertificateFromKeyChain(string keyChain, X509Certifica if (Log.IsEnabled()) { - Log.MacOSRemoveCertificateFromKeyChainStart(keyChain, GetDescription(certificate)); + Log.MacOSRemoveCertificateFromKeyChainStart(keychain, GetDescription(certificate)); } using (var process = Process.Start(processInfo)) @@ -263,12 +264,70 @@ private static void RemoveCertificateFromKeyChain(string keyChain, X509Certifica 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().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 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi 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 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi if (Log.IsEnabled()) { - Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeyChain, GetDescription(certificate)); + Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeychain, GetDescription(certificate)); } using (var process = Process.Start(processInfo)) @@ -301,20 +355,112 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi 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 GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) { return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false); } + + protected override void PopulateCertificatesFromStore(X509Store store, List certificates) + { + if (store.Name! == StoreName.My.ToString() && store.Location == store.Location && Directory.Exists(MacOSUserHttpsCertificateLocation)) + { + var certsFromDisk = GetCertsFromDisk(); + + var certsFromStore = new List(); + 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 + { + public static readonly IEqualityComparer 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.Equals(X509Certificate2 x, X509Certificate2 y) => + EqualityComparer.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.GetHashCode([DisallowNull] X509Certificate2 obj) => + EqualityComparer.Default.GetHashCode(obj.Thumbprint); + } + + private static ICollection GetCertsFromDisk() + { + var certsFromDisk = new List(); + 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); + } + } } diff --git a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs index f5970d8a1197..0dacfb93fffb 100644 --- a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs +++ b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs @@ -25,7 +25,7 @@ public CertificateManagerTests(ITestOutputHelper output, CertFixture fixture) 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 void EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttps Assert.Contains( httpsCertificate.Extensions.OfType(), 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 void ListCertificates_AlwaysReturnsTheCertificate_WithHighestVersion() Assert.Contains( firstCertificate.Extensions.OfType(), 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(), 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); } } diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index 06cd156aa944..642bdc3e5559 100644 --- a/src/Tools/dotnet-dev-certs/src/Program.cs +++ b/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 @@ public static int Main(string[] args) // 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 @@ public static int Main(string[] args) 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 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio { 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 <>'" + + "'security add-trusted-cert -p basic -p ssl -k <> <>'" + 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 <>'"); + "on the keychain. To undo these changes: 'security remove-trusted-cert <>'" + Environment.NewLine); } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -430,6 +429,12 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio 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; diff --git a/src/Tools/dotnet-dev-certs/src/dotnet-dev-certs.csproj b/src/Tools/dotnet-dev-certs/src/dotnet-dev-certs.csproj index 922933eecd39..c644415502cb 100644 --- a/src/Tools/dotnet-dev-certs/src/dotnet-dev-certs.csproj +++ b/src/Tools/dotnet-dev-certs/src/dotnet-dev-certs.csproj @@ -18,4 +18,8 @@ + + + +