/
githubmirror
/
aspnetcore
Обзор
Документация
Войти
/
githubmirror
/
aspnetcore
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/Shared/CertificateGeneration/MacOSCertificateManager.cs
490 строк
21 KB
Youssef Fahmy
Fix possible deadlock in UnixCertificateManager and MacOSCertificateManager (#66727)
02 июн 2026, 15:55
Не верифицирован
02 июн 2026, 15:55
27a6a87
Код
Авторство
О чём код?
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; using Microsoft.Win32.SafeHandles; namespace Microsoft.AspNetCore.Certificates.Generation; /// <remarks> /// Normally, we avoid the use of <see cref="X509Certificate2.Thumbprint"/> because it's a SHA-1 hash and, therefore, /// not adequate for security applications. However, the MacOS security tool uses SHA-1 hashes for certificate /// identification, so we're stuck. /// </remarks> internal sealed class MacOSCertificateManager : CertificateManager { private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; // User keychain. Guard with quotes when using in command lines since users may have set // their user profile (HOME) directory to a non-standard path that includes whitespace. 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}\""; // 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"; // 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 '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. " + "Use 'dotnet dev-certs https --trust' to trust the new certificate."; public MacOSCertificateManager() { } internal MacOSCertificateManager(string subject, int version) : base(subject, version) { } protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertificate) { var oldTrustLevel = GetTrustLevel(publicCertificate); if (oldTrustLevel != TrustLevel.None) { Debug.Assert(oldTrustLevel == TrustLevel.Full); // Mac trust is all or nothing Log.MacOSCertificateAlreadyTrusted(); return oldTrustLevel; } var tmpFile = Path.GetTempFileName(); try { // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx); if (Log.IsEnabled()) { Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {MacOSTrustCertificateCommandLineArguments}{tmpFile}"); } var exitStatus = Process.Run(new ProcessStartInfo(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile)); if (exitStatus.ExitCode != 0) { Log.MacOSTrustCommandError(exitStatus.ExitCode); throw new InvalidOperationException("There was an error trusting the certificate."); } Log.MacOSTrustCommandEnd(); return TrustLevel.Full; } finally { try { File.Delete(tmpFile); } catch { // We don't care if we can't delete the temp file. } } } internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) { return File.Exists(GetCertificateFilePath(candidate)) ? new CheckCertificateStateResult(true, null) : new CheckCertificateStateResult(false, InvalidCertificateState); } internal override void CorrectCertificateState(X509Certificate2 candidate) { try { // This path is in a well-known folder, so we trust the permissions. var certificatePath = GetCertificateFilePath(candidate); ExportCertificate(candidate, certificatePath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx); } catch (Exception ex) { Log.MacOSAddCertificateToUserProfileDirError(candidate.Thumbprint, ex.Message); } } // Use verify-cert to verify the certificate for the SSL and X.509 Basic Policy. public override TrustLevel GetTrustLevel(X509Certificate2 certificate) { var tmpFile = Path.GetTempFileName(); try { // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); using SafeFileHandle nullHandle = File.OpenNullHandle(); var checkTrustProcessStartInfo = new ProcessStartInfo( MacOSVerifyCertificateCommandLine, string.Format(CultureInfo.InvariantCulture, MacOSVerifyCertificateCommandLineArgumentsFormat, tmpFile)) { // 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. StandardOutputHandle = nullHandle, StandardErrorHandle = nullHandle }; var checkTrustProcessOutput = Process.Run(checkTrustProcessStartInfo); return checkTrustProcessOutput.ExitCode == 0 ? TrustLevel.Full : TrustLevel.None; } finally { File.Delete(tmpFile); } } protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) { if (IsCertOnKeychain(MacOSSystemKeychain, certificate)) { // 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 { RemoveAdminTrustRule(certificate); RemoveCertificateFromKeychain(MacOSSystemKeychain, certificate); } catch { } } RemoveCertificateFromUserStoreCore(certificate); } // Remove the certificate from the admin trust settings. private static void RemoveAdminTrustRule(X509Certificate2 certificate) { Log.MacOSRemoveCertificateTrustRuleStart(GetDescription(certificate)); var certificatePath = Path.GetTempFileName(); try { var certBytes = certificate.Export(X509ContentType.Cert); File.WriteAllBytes(certificatePath, certBytes); var processInfo = new ProcessStartInfo( MacOSUntrustLegacyCertificateCommandLine, string.Format( CultureInfo.InvariantCulture, MacOSUntrustLegacyCertificateCommandLineArguments, certificatePath )); var processExitStatus = Process.Run(processInfo); if (processExitStatus.ExitCode != 0) { Log.MacOSRemoveCertificateTrustRuleError(processExitStatus.ExitCode); } Log.MacOSRemoveCertificateTrustRuleEnd(); } finally { try { File.Delete(certificatePath); } catch { // We don't care if we can't delete the temp file. } } } private static void RemoveCertificateFromKeychain(string keychain, X509Certificate2 certificate) { var processInfo = new ProcessStartInfo( MacOSDeleteCertificateCommandLine, string.Format( CultureInfo.InvariantCulture, MacOSDeleteCertificateCommandLineArgumentsFormat, certificate.Thumbprint.ToUpperInvariant(), keychain )) { RedirectStandardOutput = true, RedirectStandardError = true }; if (Log.IsEnabled()) { Log.MacOSRemoveCertificateFromKeyChainStart(keychain, GetDescription(certificate)); } var processOutput = Process.RunAndCaptureText(processInfo); var exitCode = processOutput.ExitStatus.ExitCode; if (exitCode != 0) { Log.MacOSRemoveCertificateFromKeyChainError(exitCode); throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'. {processOutput.StandardOutput}{processOutput.StandardError}"); } Log.MacOSRemoveCertificateFromKeyChainEnd(); } 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. internal override bool IsExportable(X509Certificate2 c) => true; protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) { 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. CreateDirectoryWithPermissions(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); var certBytes = certificate.Export(X509ContentType.Pfx, password); var certificatePath = Path.GetTempFileName(); File.WriteAllBytes(certificatePath, certBytes); var processInfo = new ProcessStartInfo( MacOSAddCertificateToKeyChainCommandLine, string.Format(CultureInfo.InvariantCulture, MacOSAddCertificateToKeyChainCommandLineArgumentsFormat, certificatePath, password)) { RedirectStandardOutput = true, RedirectStandardError = true }; if (Log.IsEnabled()) { Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeychain, GetDescription(certificate)); } var processOutput = Process.RunAndCaptureText(processInfo); var exitCode = processOutput.ExitStatus.ExitCode; if (exitCode != 0) { Log.MacOSAddCertificateToKeyChainError(exitCode, processOutput.StandardOutput + processOutput.StandardError); throw new InvalidOperationException("Failed to add the certificate to the keychain. Are you running in a non-interactive session perhaps?"); } Log.MacOSAddCertificateToKeyChainEnd(); } 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, bool requireExportable) { if (store.Name! == StoreName.My.ToString() && store.Location == StoreLocation.CurrentUser && Directory.Exists(MacOSUserHttpsCertificateLocation)) { var certsFromDisk = GetCertsFromDisk(); var certsFromStore = new List<X509Certificate2>(); base.PopulateCertificatesFromStore(store, certsFromStore, requireExportable); // 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). // Note that if we require exportable certs, the actual certs we populate need to be the ones // from the store location, and not the version from disk. If we don't require exportability, // we favor the version of the cert that's on disk (avoiding unnecessary keychain access // prompts). Intersect compares with the specified comparer and returns the matching elements // from the first set. var onDiskAndKeychain = requireExportable ? certsFromStore.Intersect(certsFromDisk, ThumbprintComparer.Instance) : 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, requireExportable); } } 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); } } protected override void CreateDirectoryWithPermissions(string directoryPath) { #pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows) var dirInfo = new DirectoryInfo(directoryPath); if (dirInfo.Exists) { if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0) { Log.DirectoryPermissionsNotSecure(dirInfo.FullName); } } else { Directory.CreateDirectory(directoryPath, DirectoryPermissions); } #pragma warning restore CA1416 // Validate platform compatibility } }