From 19765dac729953c0441bf01cec5698b42d676de1 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 14 Jun 2024 09:56:38 -0700 Subject: [PATCH 01/12] Add support for trusting dev certs on linux There's no consistent way to do this that works for all clients on all Linux distros, but this approach gives us pretty good coverage. In particular, we aim to support .NET (esp HttpClient), Chromium, and Firefox on Ubuntu- and Fedora-based distros. Certificate trust is applied per-user, which is simpler and preferable for security reasons, but comes with the notable downside that the process can't be completed within the tool - the user has to update an environment variable, probably in their user profile. In particular, OpenSSL consumes the `SSL_CERT_DIR` environment variable to determine where it should look for trusted certificates. We break establishing trust into two categories: OpenSSL, which backs .NET, and NSS databases (henceforth, nssdb), which backs browsers. To establish trust in OpenSSL, we put the certificate in `~/.dotnet/corefx/cryptography/trusted`, run a simplified version of OpenSSL's `c_rehash` tool on the directory, and ask the user to update `SSL_CERT_DIR`. To establish trust in nssdb, we search the home directory for Firefox profiles and `~/.pki/nssdb`. For each one found, we add an entry to the nssdb therein. Each of these locations (the trusted certificate folder and the list of nssdbs) can be overridden with an environment variable. This large number of steps introduces a problem that doesn't exist on Windows or macOS - the dev cert can end up trusted by some clients but not by others. This change introduces a `TrustLevel` concept so that we can produce clearer output when this happens. The only non-bundled tools required to update certificate trust are `openssl` (the CLI) and `certutil`. `sudo` is not required, since all changes are within the user's home directory. --- .../Core/src/Internal/LoggerExtensions.cs | 3 + .../Kestrel/Core/src/KestrelServerOptions.cs | 9 +- .../CertificateManager.cs | 164 +++- .../EnsureCertificateResult.cs | 1 + .../MacOSCertificateManager.cs | 15 +- .../UnixCertificateManager.cs | 755 +++++++++++++++++- .../WindowsCertificateManager.cs | 10 +- src/Tools/dotnet-dev-certs/src/Program.cs | 52 +- 8 files changed, 959 insertions(+), 50 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs b/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs index 35d5505ebe8b..8a2fb29198f2 100644 --- a/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs +++ b/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs @@ -40,4 +40,7 @@ internal static partial class LoggerExtensions [LoggerMessage(8, LogLevel.Warning, "The ASP.NET Core developer certificate is not trusted. For information about trusting the ASP.NET Core developer certificate, see https://aka.ms/aspnet/https-trust-dev-cert", EventName = "DeveloperCertificateNotTrusted")] public static partial void DeveloperCertificateNotTrusted(this ILogger logger); + + [LoggerMessage(9, LogLevel.Warning, "The ASP.NET Core developer certificate is only trusted by some clients. For information about trusting the ASP.NET Core developer certificate, see https://aka.ms/aspnet/https-trust-dev-cert", EventName = "DeveloperCertificatePartiallyTrusted")] + public static partial void DeveloperCertificatePartiallyTrusted(this ILogger logger); } diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index f16b35437001..bd05af118ee0 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -398,9 +398,14 @@ internal void Serialize(Utf8JsonWriter writer) return null; } - if (!CertificateManager.Instance.IsTrusted(cert)) + switch (CertificateManager.Instance.GetTrustLevel(cert)) { - logger.DeveloperCertificateNotTrusted(); + case CertificateManager.TrustLevel.Partial: + logger.DeveloperCertificatePartiallyTrusted(); + break; + case CertificateManager.TrustLevel.None: + logger.DeveloperCertificateNotTrusted(); + break; } return cert; diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index c169d1a17047..e39368f22133 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -333,7 +333,20 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( { try { - TrustCertificate(certificate); + var trustLevel = TrustCertificate(certificate); + switch (trustLevel) + { + case TrustLevel.Full: + // Leave result as-is. + break; + case TrustLevel.Partial: + result = EnsureCertificateResult.PartiallyFailedToTrustTheCertificate; + return result; + case TrustLevel.None: + default: // Treat unknown status (should be impossible) as failure + result = EnsureCertificateResult.FailedToTrustTheCertificate; + return result; + } } catch (UserCancelledTrustException) { @@ -443,11 +456,12 @@ public void CleanupHttpsCertificates() } } - public abstract bool IsTrusted(X509Certificate2 certificate); + public abstract TrustLevel GetTrustLevel(X509Certificate2 certificate); protected abstract X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation); - protected abstract void TrustCertificateCore(X509Certificate2 certificate); + /// Implementations may choose to throw, rather than returning . + protected abstract TrustLevel TrustCertificateCore(X509Certificate2 certificate); protected abstract bool IsExportable(X509Certificate2 c); @@ -665,7 +679,7 @@ internal X509Certificate2 SaveCertificate(X509Certificate2 certificate) return certificate; } - internal void TrustCertificate(X509Certificate2 certificate) + internal TrustLevel TrustCertificate(X509Certificate2 certificate) { try { @@ -673,8 +687,9 @@ internal void TrustCertificate(X509Certificate2 certificate) { Log.TrustCertificateStart(GetDescription(certificate)); } - TrustCertificateCore(certificate); + var trustLevel = TrustCertificateCore(certificate); Log.TrustCertificateEnd(); + return trustLevel; } catch (Exception ex) when (Log.IsEnabled()) { @@ -856,6 +871,9 @@ internal static bool TryFindCertificateInStore(X509Store store, X509Certificate2 return foundCertificate is not null; } + /// + /// Note that dotnet-dev-certs won't display any of these, regardless of level, unless --verbose is passed. + /// [EventSource(Name = "Dotnet-dev-certs")] public sealed class CertificateManagerEventSource : EventSource { @@ -906,7 +924,7 @@ public sealed class CertificateManagerEventSource : EventSource public void CreateDevelopmentCertificateError(string e) => WriteEvent(19, e); [Event(20, Level = EventLevel.Verbose, Message = "Saving certificate '{0}' to store {2}\\{1}.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Parameters passed to WriteEvent are all primative values.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Parameters passed to WriteEvent are all primitive values.")] public void SaveCertificateInStoreStart(string certificate, StoreName name, StoreLocation location) => WriteEvent(20, certificate, name, location); [Event(21, Level = EventLevel.Verbose, Message = "Finished saving certificate to the store.")] @@ -1061,6 +1079,130 @@ public sealed class CertificateManagerEventSource : EventSource [Event(71, Level = EventLevel.Warning, Message = "The on-disk store directory was not found.")] internal void MacOSDiskStoreDoesNotExist() => WriteEvent(71); + + [Event(72, Level = EventLevel.Verbose, Message = "Reading OpenSSL trusted certificates location from {0}.")] + internal void UnixOpenSslCertificateDirectoryOverridePresent(string nssDbOverrideVariableName) => WriteEvent(72, nssDbOverrideVariableName); + + [Event(73, Level = EventLevel.Verbose, Message = "Reading NSS database locations from {0}.")] + internal void UnixNssDbOverridePresent(string environmentVariable) => WriteEvent(73, environmentVariable); + + // Recoverable - just don't use it. + [Event(74, Level = EventLevel.Warning, Message = "The NSS database '{0}' provided via {1} does not exist.")] + internal void UnixNssDbDoesNotExist(string nssDb, string environmentVariable) => WriteEvent(74, nssDb, environmentVariable); + + [Event(75, Level = EventLevel.Warning, Message = "The certificate is not trusted by OpenSSL. This will likely affect System.Net.Http.HttpClient.")] + internal void UnixNotTrustedByOpenSsl() => WriteEvent(75); + + [Event(76, Level = EventLevel.Warning, Message = "The certificate is not trusted by OpenSSL. Ensure that the {0} environment variable is set correctly. This will likely affect System.Net.Http.HttpClient.")] + internal void UnixNotTrustedByOpenSslVariableUnset(string envVarName) => WriteEvent(76, envVarName); + + [Event(77, Level = EventLevel.Warning, Message = "The certificate is not trusted in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] + internal void UnixNotTrustedByNss(string path, string browser) => WriteEvent(77, path, browser); + + // If there's no home directory, there are no NSS DBs to check (barring an override), but this isn't strictly a problem. + [Event(78, Level = EventLevel.Verbose, Message = "Home directory '{0}' does not exist. Unable to discover NSS databases for user '{1}'. This will likely affect browsers.")] + internal void UnixHomeDirectoryDoesNotExist(string homeDirectory, string username) => WriteEvent(78, homeDirectory, username); + + // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. + [Event(79, Level = EventLevel.Verbose, Message = "OpenSSL reported its directory in an unexpected format.")] + internal void UnixOpenSslVersionParsingFailed() => WriteEvent(79); + + // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. + [Event(80, Level = EventLevel.Verbose, Message = "Unable to determine the OpenSSL directory.")] + internal void UnixOpenSslVersionFailed() => WriteEvent(80); + + // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. + [Event(81, Level = EventLevel.Verbose, Message = "Unable to determine the OpenSSL directory: {0}.")] + internal void UnixOpenSslVersionException(string exceptionMessage) => WriteEvent(81, exceptionMessage); + + // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. + [Event(82, Level = EventLevel.Error, Message = "Unable to compute the hash of certificate {0}. OpenSSL trust is likely in an inconsistent state.")] + internal void UnixOpenSslHashFailed(string certificatePath) => WriteEvent(82, certificatePath); + + // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. + [Event(83, Level = EventLevel.Error, Message = "Unable to compute the certificate hash: {0}. OpenSSL trust is likely in an inconsistent state.")] + internal void UnixOpenSslHashException(string certificatePath, string exceptionMessage) => WriteEvent(83, certificatePath, exceptionMessage); + + // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. + [Event(84, Level = EventLevel.Error, Message = "Unable to update certificate '{0}' in the OpenSSL trusted certificate hash collection - {2} certificates have the hash {1}.")] + internal void UnixOpenSslRehashTooManyHashes(string fullName, string hash, int maxHashCollisions) => WriteEvent(84, fullName, hash, maxHashCollisions); + + // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. + [Event(85, Level = EventLevel.Error, Message = "Unable to update the OpenSSL trusted certificate hash collection: {0}. " + + "Manually rehashing may help. See https://aka.ms/dev-certs-trust for more information.")] // This should recommend manually running c_rehash. + internal void UnixOpenSslRehashException(string exceptionMessage) => WriteEvent(85, exceptionMessage); + + [Event(86, Level = EventLevel.Warning, Message = "Clients that validate certificate trust using OpenSSL, including System.Net.Http.HttpClient, will not trust the certificate.")] + internal void UnixOpenSslTrustFailed() => WriteEvent(86); + + [Event(87, Level = EventLevel.Verbose, Message = "Trusted the certificate in OpenSSL.")] + internal void UnixOpenSslTrustSucceeded() => WriteEvent(87); + + [Event(88, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] + internal void UnixNssDbTrustFailed(string path, string browser) => WriteEvent(88, path, browser); + + [Event(89, Level = EventLevel.Verbose, Message = "Trusted the certificate in the NSS database in '{0}'.")] + internal void UnixNssDbTrustSucceeded(string path) => WriteEvent(89, path); + + [Event(90, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustFailed() => WriteEvent(90); + + [Event(91, Level = EventLevel.Verbose, Message = "Untrusted the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustSucceeded() => WriteEvent(91); + + [Event(92, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}'.")] + internal void UnixNssDbUntrustFailed(string path) => WriteEvent(92, path); + + [Event(93, Level = EventLevel.Verbose, Message = "Removed the certificate from the NSS database in '{0}'.")] + internal void UnixNssDbUntrustSucceeded(string path) => WriteEvent(93, path); + + [Event(94, Level = EventLevel.Warning, Message = "The certificate is only partially trusted - some clients will not accept it.")] + internal void UnixTrustPartiallySucceeded() => WriteEvent(94); + + [Event(95, Level = EventLevel.Warning, Message = "Failed to look up the certificate in the NSS database in '{0}': {1}.")] + internal void UnixNssDbCheckException(string path, string exceptionMessage) => WriteEvent(95, path, exceptionMessage); + + [Event(96, Level = EventLevel.Warning, Message = "Failed to add the certificate to the NSS database in '{0}': {1}.")] + internal void UnixNssDbAdditionException(string path, string exceptionMessage) => WriteEvent(96, path, exceptionMessage); + + [Event(97, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}': {1}.")] + internal void UnixNssDbRemovalException(string path, string exceptionMessage) => WriteEvent(97, path, exceptionMessage); + + [Event(98, Level = EventLevel.Warning, Message = "Failed to find the Firefox profiles in directory '{0}': {1}.")] + internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => WriteEvent(98, firefoxDirectory, message); + + [Event(99, Level = EventLevel.Verbose, Message = "No Firefox profiles found in directory '{0}'.")] + internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => WriteEvent(99, firefoxDirectory); + + [Event(100, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers. " + + "This likely indicates that the database already contains an entry for the certificate under a different name. Please remove it and try again.")] + internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => WriteEvent(100, path, browser); + + // This may be annoying, since anyone setting the variable for un/trust will likely leave it set for --check. + // However, it seems important to warn users who set it specifically for --check. + [Event(101, Level = EventLevel.Warning, Message = "The {0} environment variable is set but will not be consumed while checking trust.")] + internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => WriteEvent(101, openSslCertDirectoryOverrideVariableName); + + [Event(102, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for updating certificate trust in OpenSSL, which is used by System.Net.Http.HttpClient.")] + internal void UnixMissingOpenSslCommand(string openSslCommand) => WriteEvent(102, openSslCommand); + + [Event(103, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.")] + internal void UnixMissingCertUtilCommand(string certUtilCommand) => WriteEvent(103, certUtilCommand); + + [Event(104, Level = EventLevel.Verbose, Message = "Untrusting the certificate in OpenSSL was skipped since '{0}' does not exist.")] + internal void UnixOpenSslUntrustSkipped(string certPath) => WriteEvent(104, certPath); + + [Event(105, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] + internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(105, certPath, exceptionMessage); + + [Event(106, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + "For example, `export SSL_CERT_DIR={0}:{1}`. " + + "See https://aka.ms/dev-certs-trust for more information.")] + internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(106, certDir, openSslDir, envVarName); + + [Event(107, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + "See https://aka.ms/dev-certs-trust for more information.")] + internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(107, certDir, envVarName); } internal sealed class UserCancelledTrustException : Exception @@ -1086,4 +1228,14 @@ internal enum RemoveLocations Trusted, All } + + internal enum TrustLevel + { + /// No trust has been granted. + None, + /// Trust has been granted in some, but not all, clients. + Partial, + /// Trust has been granted in all clients. + Full, + } } diff --git a/src/Shared/CertificateGeneration/EnsureCertificateResult.cs b/src/Shared/CertificateGeneration/EnsureCertificateResult.cs index 842b84a3643d..cb6b7e145428 100644 --- a/src/Shared/CertificateGeneration/EnsureCertificateResult.cs +++ b/src/Shared/CertificateGeneration/EnsureCertificateResult.cs @@ -11,6 +11,7 @@ internal enum EnsureCertificateResult ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore, ErrorExportingTheCertificate, FailedToTrustTheCertificate, + PartiallyFailedToTrustTheCertificate, UserCancelledTrustStep, FailedToMakeKeyAccessible, ExistingHttpsCertificateTrusted, diff --git a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs index f55c57025e55..cf792a904b89 100644 --- a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs @@ -69,7 +69,7 @@ internal sealed class MacOSCertificateManager : CertificateManager "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."; + "Use 'dotnet dev-certs https --trust' to trust the new certificate."; public const string KeyNotAccessibleWithoutUserInteraction = "The application is trying to access the ASP.NET Core developer certificate key. " + @@ -85,12 +85,14 @@ internal MacOSCertificateManager(string subject, int version) { } - protected override void TrustCertificateCore(X509Certificate2 publicCertificate) + protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertificate) { - if (IsTrusted(publicCertificate)) + var oldTrustLevel = GetTrustLevel(publicCertificate); + if (oldTrustLevel != TrustLevel.None) { + Debug.Assert(oldTrustLevel == TrustLevel.Full); // Mac trust is all or nothing Log.MacOSCertificateAlreadyTrusted(); - return; + return oldTrustLevel; } var tmpFile = Path.GetTempFileName(); @@ -111,6 +113,7 @@ protected override void TrustCertificateCore(X509Certificate2 publicCertificate) } } Log.MacOSTrustCommandEnd(); + return TrustLevel.Full; } finally { @@ -149,7 +152,7 @@ internal override void CorrectCertificateState(X509Certificate2 candidate) } // Use verify-cert to verify the certificate for the SSL and X.509 Basic Policy. - public override bool IsTrusted(X509Certificate2 certificate) + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) { var tmpFile = Path.GetTempFileName(); try @@ -166,7 +169,7 @@ public override bool IsTrusted(X509Certificate2 certificate) RedirectStandardError = true, }); checkTrustProcess!.WaitForExit(); - return checkTrustProcess.ExitCode == 0; + return checkTrustProcess.ExitCode == 0 ? TrustLevel.Full : TrustLevel.None; } finally { diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index d7a8fc1acb6b..25d85dece2d4 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -1,14 +1,35 @@ // 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; +#nullable enable + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; namespace Microsoft.AspNetCore.Certificates.Generation; -internal sealed class UnixCertificateManager : CertificateManager +internal sealed partial class UnixCertificateManager : CertificateManager { + /// The name of an environment variable consumed by OpenSSL to locate certificates. + private const string OpenSslCertificateDirectoryVariableName = "SSL_CERT_DIR"; + + private const string OpenSslCertDirectoryOverrideVariableName = "DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY"; + private const string NssDbOverrideVariableName = "DOTNET_DEV_CERTS_NSSDB_PATHS"; + // CONSIDER: we could have a distinct variable for Mozilla NSS DBs, but detecting them from the path seems sufficient for now. + + private const string BrowserFamilyChromium = "Chromium"; + private const string BrowserFamilyFirefox = "Firefox"; + + private const string OpenSslCommand = "openssl"; + private const string CertUtilCommand = "certutil"; + + private const int MaxHashCollisions = 10; // Something is going badly wrong if we have this many dev certs with the same hash + + private HashSet? _availableCommands; + public UnixCertificateManager() { } @@ -18,13 +39,75 @@ internal UnixCertificateManager(string subject, int version) { } - public override bool IsTrusted(X509Certificate2 certificate) + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) { - using X509Chain chain = new X509Chain(); + var sawTrustSuccess = false; + var sawTrustFailure = false; + + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName))) + { + // Warn but don't bail. + Log.UnixOpenSslCertificateDirectoryOverrideIgnored(OpenSslCertDirectoryOverrideVariableName); + } + + // Building the chain will check whether openssl (which covers HttpClient) trusts the cert. + // An alternative approach would be to look for the file and link in the trust folder, but + // this tests the real-world behavior. + using var chain = new X509Chain(); // This is just a heuristic for whether or not we should prompt the user to re-run with `--trust` // so we don't need to check revocation (which doesn't really make sense for dev certs anyway) chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - return chain.Build(certificate); + if (chain.Build(certificate)) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName))) + { + Log.UnixNotTrustedByOpenSslVariableUnset(OpenSslCertificateDirectoryVariableName); + } + else + { + Log.UnixNotTrustedByOpenSsl(); + } + } + + var nssDbs = GetNssDbs(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + if (nssDbs.Count > 0) + { + if (!IsCommandAvailable(CertUtilCommand)) + { + // If there are browsers but we don't have certutil, we can't check trust and, + // in all probability, we can't have previously established it. + Log.UnixMissingCertUtilCommand(CertUtilCommand); + sawTrustFailure = true; + } + else + { + var nickname = GetCertificateNickname(certificate); + foreach (var nssDb in nssDbs) + { + if (IsCertificateInNssDb(nickname, nssDb)) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByNss(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + } + } + } + } + + return sawTrustSuccess + ? sawTrustFailure + ? TrustLevel.Partial + : TrustLevel.Full + : TrustLevel.None; } protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) @@ -47,26 +130,680 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive) { // Return true as we don't perform any check. + // This is about checking storage, not trust. return new CheckCertificateStateResult(true, null); } internal override void CorrectCertificateState(X509Certificate2 candidate) { // Do nothing since we don't have anything to check here. + // This is about correcting storage, not trust. } protected override bool IsExportable(X509Certificate2 c) => true; - protected override void TrustCertificateCore(X509Certificate2 certificate) => - throw new InvalidOperationException("Trusting the certificate is not supported on linux"); + protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) + { + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + // Rather than create a temporary file we'll have to clean up, we prefer to export the dev cert + // to its final location in the OpenSSL directory. As a result, any failure up until that point + // is fatal (i.e. we can't trust the cert in other locations). + + var certDir = GetOpenSslCertificateDirectory(homeDirectory)!; // May not exist + + var nickname = GetCertificateNickname(certificate); + var certPath = Path.Combine(certDir, nickname) + ".pem"; + + // Security: we don't need the private key for trust, so we don't export it. + // Note that this will create directories as needed. + ExportCertificate(certificate, certPath, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); + + // Once the certificate is on disk, we prefer not to throw - some subsequent trust step might succeed. + + var sawTrustFailure = false; + var sawTrustSuccess = false; + + var openSslTrustSucceeded = false; + + var isOpenSslAvailable = IsCommandAvailable(OpenSslCommand); + if (isOpenSslAvailable) + { + if (TryRehashOpenSslCertificates(certDir)) + { + openSslTrustSucceeded = true; + } + } + else + { + Log.UnixMissingOpenSslCommand(OpenSslCommand); + } + + if (openSslTrustSucceeded) + { + Log.UnixOpenSslTrustSucceeded(); + sawTrustSuccess = true; + } + else + { + // The helpers log their own failure reasons - we just describe the consequences + Log.UnixOpenSslTrustFailed(); + sawTrustFailure = true; + } + + var nssDbs = GetNssDbs(homeDirectory); + if (nssDbs.Count > 0) + { + var isCertUtilAvailable = IsCommandAvailable(CertUtilCommand); + if (!isCertUtilAvailable) + { + Log.UnixMissingCertUtilCommand(CertUtilCommand); + // We'll loop over the nssdbs anyway so they'll be listed + } + + foreach (var nssDb in nssDbs) + { + if (isCertUtilAvailable && TryAddCertificateToNssDb(certPath, nickname, nssDb)) + { + if (IsCertificateInNssDb(nickname, nssDb)) + { + Log.UnixNssDbTrustSucceeded(nssDb.Path); + sawTrustSuccess = true; + } + else + { + // If the dev cert is in the db under a different nickname, adding it will succeed (and probably even cause it to be trusted) + // but IsTrusted won't find it. This is unlikely to happen in practice, so we warn here, rather than hardening IsTrusted. + Log.UnixNssDbTrustFailedWithProbableConflict(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + sawTrustFailure = true; + } + } + else + { + Log.UnixNssDbTrustFailed(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + sawTrustFailure = true; + } + } + } + + if (sawTrustFailure) + { + if (sawTrustSuccess) + { + // Untrust throws in this case, but we're more lenient since a partially trusted state may be useful in practice. + Log.UnixTrustPartiallySucceeded(); + } + else + { + return TrustLevel.None; + } + } + + if (openSslTrustSucceeded) + { + Debug.Assert(IsCommandAvailable(OpenSslCommand), "How did we trust without the openssl command?"); + + var homeDirectoryWithSlash = homeDirectory[^1] == Path.DirectorySeparatorChar + ? homeDirectory + : homeDirectory + Path.DirectorySeparatorChar; + + var prettyCertDir = certDir.StartsWith(homeDirectoryWithSlash, StringComparison.Ordinal) + ? Path.Combine("$HOME", certDir[homeDirectoryWithSlash.Length..]) + : certDir; + + if (TryGetOpenSslDirectory(out var openSslDir)) + { + Log.UnixSuggestSettingEnvironmentVariable(prettyCertDir, Path.Combine(openSslDir, "certs"), OpenSslCertificateDirectoryVariableName); + } + else + { + Log.UnixSuggestSettingEnvironmentVariableWithoutExample(prettyCertDir, OpenSslCertificateDirectoryVariableName); + } + } + + return sawTrustFailure + ? TrustLevel.Partial + : TrustLevel.Full; + } protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) { - // No-op here as is benign + var sawUntrustFailure = false; + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)!; + + // We don't attempt to clean this up when it's empty - it's a standard location + // and will almost certainly be used in the future. + var certDir = GetOpenSslCertificateDirectory(homeDirectory); // May not exist + + var nickname = GetCertificateNickname(certificate); + var certPath = Path.Combine(certDir, nickname) + ".pem"; + + if (File.Exists(certPath)) + { + var openSslUntrustSucceeded = false; + + if (IsCommandAvailable(OpenSslCommand)) + { + if (TryDeleteCertificateFile(certPath) && TryRehashOpenSslCertificates(certDir)) + { + openSslUntrustSucceeded = true; + } + } + else + { + Log.UnixMissingOpenSslCommand(OpenSslCommand); + } + + if (openSslUntrustSucceeded) + { + Log.UnixOpenSslUntrustSucceeded(); + } + else + { + // The helpers log their own failure reasons - we just describe the consequences + Log.UnixOpenSslUntrustFailed(); + sawUntrustFailure = true; + } + } + else + { + Log.UnixOpenSslUntrustSkipped(certPath); + } + + var nssDbs = GetNssDbs(homeDirectory); + if (nssDbs.Count > 0) + { + var isCertUtilAvailable = IsCommandAvailable(CertUtilCommand); + if (!isCertUtilAvailable) + { + Log.UnixMissingCertUtilCommand(CertUtilCommand); + // We'll loop over the nssdbs anyway so they'll be listed + } + + foreach (var nssDb in nssDbs) + { + if (isCertUtilAvailable && TryRemoveCertificateFromNssDb(nickname, nssDb)) + { + Log.UnixNssDbUntrustSucceeded(nssDb.Path); + } + else + { + Log.UnixNssDbUntrustFailed(nssDb.Path); + sawUntrustFailure = true; + } + } + } + + if (sawUntrustFailure) + { + // It might be nice to include more specific error information in the exception message, but we've logged it anyway. + throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'."); + } } protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) { return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false); } + + private static string GetChromiumNssDb(string homeDirectory) + { + return Path.Combine(homeDirectory, ".pki", "nssdb"); + } + + private static string GetFirefoxDirectory(string homeDirectory) + { + return Path.Combine(homeDirectory, ".mozilla", "firefox"); + } + + private static string GetFirefoxSnapDirectory(string homeDirectory) + { + return Path.Combine(homeDirectory, "snap", "firefox", "common", ".mozilla", "firefox"); + } + + private bool IsCommandAvailable(string command) + { + _availableCommands ??= FindAvailableCommands(); + return _availableCommands.Contains(command); + } + + private static HashSet FindAvailableCommands() + { + var availableCommands = new HashSet(); + + // We need OpenSSL 1.1.1h or newer (to pick up https://github.com/openssl/openssl/pull/12357), + // but, given that all of v1 is EOL, it doesn't seem worthwhile to check the version. + var commands = new[] { OpenSslCommand, CertUtilCommand }; + + var searchPath = Environment.GetEnvironmentVariable("PATH"); + + if (searchPath is null) + { + return availableCommands; + } + + var searchFolders = searchPath.Split(Path.PathSeparator); + + foreach (var searchFolder in searchFolders) + { + foreach (var command in commands) + { + if (!availableCommands.Contains(command)) + { + try + { + if (File.Exists(Path.Combine(searchFolder, command))) + { + availableCommands.Add(command); + } + } + catch + { + // It's not interesting to report (e.g.) permission errors here. + } + } + } + + // Stop early if we've found all the required commands. + // They're usually all in the same folder (/bin or /usr/bin). + if (availableCommands.Count == commands.Length) + { + break; + } + } + + return availableCommands; + } + + private static string GetCertificateNickname(X509Certificate2 certificate) + { + return $"aspnetcore-localhost-{certificate.Thumbprint[0..6]}"; + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool IsCertificateInNssDb(string nickname, NssDb nssDb) + { + // -V will validate that a cert can be used for a given purpose, in this case, server verification. + // There is no corresponding -V check for the "Trusted CA" status required by Firefox, so we just check for existence. + // (The docs suggest that "-V -u A" should do this, but it seems to accept all certs.) + var operation = nssDb.IsFirefox ? "-L" : "-V -u V"; + + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -n {nickname} {operation}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (Exception ex) + { + Log.UnixNssDbCheckException(nssDb.Path, ex.Message); + // This method is used to determine whether more trust is needed, so it's better to underestimate the amount of trust. + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool TryAddCertificateToNssDb(string certificatePath, string nickname, NssDb nssDb) + { + // Firefox doesn't seem to respected the more correct "trusted peer" (P) usage, so we use "trusted CA" (C) instead. + var usage = nssDb.IsFirefox ? "C" : "P"; + + // This silently clobbers an existing entry, so there's no need to check for existence first. + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -n {nickname} -A -i {certificatePath} -t \"{usage},,\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (Exception ex) + { + Log.UnixNssDbAdditionException(nssDb.Path, ex.Message); + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb) + { + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -D -n {nickname}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + if (process.ExitCode == 0) + { + return true; + } + + // Maybe it wasn't in there because the overrides have change or trust only partially succeeded. + return !IsCertificateInNssDb(nickname, nssDb); + } + catch (Exception ex) + { + Log.UnixNssDbRemovalException(nssDb.Path, ex.Message); + return false; + } + } + + private static IEnumerable GetFirefoxProfiles(string firefoxDirectory) + { + try + { + var profiles = Directory.GetDirectories(firefoxDirectory, "*.default", SearchOption.TopDirectoryOnly).Concat( + Directory.GetDirectories(firefoxDirectory, "*.default-*", SearchOption.TopDirectoryOnly)); // There can be one of these for each release channel + if (!profiles.Any()) + { + // This is noteworthy, given that we're in a firefox directory. + Log.UnixNoFirefoxProfilesFound(firefoxDirectory); + } + return profiles; + } + catch (Exception ex) + { + Log.UnixFirefoxProfileEnumerationException(firefoxDirectory, ex.Message); + return []; + } + } + + private static string GetOpenSslCertificateDirectory(string homeDirectory) + { + var @override = Environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName); + if (!string.IsNullOrEmpty(@override)) + { + Log.UnixOpenSslCertificateDirectoryOverridePresent(OpenSslCertDirectoryOverrideVariableName); + return @override; + } + + return Path.Combine(homeDirectory, ".dotnet", "corefx", "cryptography", "trusted"); + } + + private static bool TryDeleteCertificateFile(string certPath) + { + try + { + File.Delete(certPath); + return true; + } + catch (Exception ex) + { + Log.UnixCertificateFileDeletionException(certPath, ex.Message); + return false; + } + } + + private static bool TryGetNssDbOverrides(out IReadOnlyList overrides) + { + var nssDbOverride = Environment.GetEnvironmentVariable(NssDbOverrideVariableName); + if (string.IsNullOrEmpty(nssDbOverride)) + { + overrides = []; + return false; + } + + // Normally, we'd let the caller log this, since it's not really an exceptional condition, + // but it's not worth duplicating the code and the work. + Log.UnixNssDbOverridePresent(NssDbOverrideVariableName); + + var nssDbs = new List(); + + var paths = nssDbOverride.Split(Path.PathSeparator); // May be empty - the user may not want to add browser trust + foreach (var path in paths) + { + var nssDb = Path.GetFullPath(path); + if (!Directory.Exists(nssDb)) + { + Log.UnixNssDbDoesNotExist(nssDb, NssDbOverrideVariableName); + continue; + } + nssDbs.Add(nssDb); + } + + overrides = nssDbs; + return true; + } + + private static List GetNssDbs(string homeDirectory) + { + var nssDbs = new List(); + + if (TryGetNssDbOverrides(out var nssDbOverrides)) + { + foreach (var nssDb in nssDbOverrides) + { + // Our Firefox approach is a hack, so we'd rather under-recognize it than over-recognize it. + var isFirefox = nssDb.Contains("/.mozilla/firefox/", StringComparison.Ordinal); + nssDbs.Add(new NssDb(nssDb, isFirefox)); + } + + return nssDbs; + } + + if (!Directory.Exists(homeDirectory)) + { + Log.UnixHomeDirectoryDoesNotExist(homeDirectory, Environment.UserName); + return nssDbs; + } + + // Chrome, Chromium, Edge, and their respective snaps all use this directory + var chromiumNssDb = GetChromiumNssDb(homeDirectory); + if (Directory.Exists(chromiumNssDb)) + { + nssDbs.Add(new NssDb(chromiumNssDb, isFirefox: false)); + } + + var firefoxDir = GetFirefoxDirectory(homeDirectory); + if (Directory.Exists(firefoxDir)) + { + var profileDirs = GetFirefoxProfiles(firefoxDir); + foreach (var profileDir in profileDirs) + { + nssDbs.Add(new NssDb(profileDir, isFirefox: true)); + } + } + + var firefoxSnapDir = GetFirefoxSnapDirectory(homeDirectory); + if (Directory.Exists(firefoxSnapDir)) + { + var profileDirs = GetFirefoxProfiles(firefoxSnapDir); + foreach (var profileDir in profileDirs) + { + nssDbs.Add(new NssDb(profileDir, isFirefox: true)); + } + } + + return nssDbs; + } + + [GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")] + private static partial Regex OpenSslVersionRegex(); + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir) + { + openSslDir = null; + + try + { + var processInfo = new ProcessStartInfo(OpenSslCommand, $"version -d") + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using var process = Process.Start(processInfo); + var stdout = process!.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.UnixOpenSslVersionFailed(); + return false; + } + + var match = OpenSslVersionRegex().Match(stdout); + if (!match.Success) + { + Log.UnixOpenSslVersionParsingFailed(); + return false; + } + + openSslDir = match.Groups[1].Value; + return true; + } + catch (Exception ex) + { + Log.UnixOpenSslVersionException(ex.Message); + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true)] out string? hash) + { + hash = null; + + try + { + // c_rehash actually does this twice: once with -subject_hash (equivalent to -hash) and again + // with -subject_hash_old. Old hashes are only needed for pre-1.0.0, so we skip that. + var processInfo = new ProcessStartInfo(OpenSslCommand, $"x509 -hash -noout -in {certificatePath}") + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using var process = Process.Start(processInfo); + var stdout = process!.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.UnixOpenSslHashFailed(certificatePath); + return false; + } + + hash = stdout.Trim(); + return true; + } + catch (Exception ex) + { + Log.UnixOpenSslHashException(certificatePath, ex.Message); + return false; + } + } + + [GeneratedRegex("^[0-9a-f]+\\.[0-9]+$")] + private static partial Regex OpenSslHashFilenameRegex(); + + /// + /// We only ever use .pem, but someone will eventually put their own cert in this directory, + /// so we should handle the same extensions as c_rehash (other than .crl). + /// + [GeneratedRegex("\\.(pem|crt|cer)$")] + private static partial Regex OpenSslCertificateExtensionRegex(); + + /// + /// This is a simplified version of c_rehash from OpenSSL. Using the real one would require + /// installing the OpenSSL perl tools and perl itself, which might be annoying in a container. + /// + private static bool TryRehashOpenSslCertificates(string certificateDirectory) + { + try + { + // First, delete all the existing symlinks, so we don't have to worry about fragmentation or leaks. + + var hashRegex = OpenSslHashFilenameRegex(); + var extensionRegex = OpenSslCertificateExtensionRegex(); + + var certs = new List(); + + var dirInfo = new DirectoryInfo(certificateDirectory); + foreach (var file in dirInfo.EnumerateFiles()) + { + var isSymlink = (file.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + if (isSymlink && hashRegex.IsMatch(file.Name)) + { + file.Delete(); + } + else if (extensionRegex.IsMatch(file.Name)) + { + certs.Add(file); + } + } + + // Then, enumerate all certificates - there will usually be zero or one. + + // c_rehash doesn't create additional symlinks for certs with the same fingerprint, + // but we don't expect this to happen, so we favor slightly slower look-ups when it + // does, rather than slightly slower rehashing when it doesn't. + + foreach (var cert in certs) + { + if (!TryGetOpenSslHash(cert.FullName, out var hash)) + { + return false; + } + + var linkCreated = false; + for (var i = 0; i < MaxHashCollisions; i++) + { + var linkPath = Path.Combine(certificateDirectory, $"{hash}.{i}"); + if (!File.Exists(linkPath)) + { + // As in c_rehash, we link using a relative path. + File.CreateSymbolicLink(linkPath, cert.Name); + linkCreated = true; + break; + } + } + + if (!linkCreated) + { + Log.UnixOpenSslRehashTooManyHashes(cert.FullName, hash, MaxHashCollisions); + return false; + } + } + } + catch (Exception ex) + { + Log.UnixOpenSslRehashException(ex.Message); + return false; + } + + return true; + } + + private sealed class NssDb(string path, bool isFirefox) + { + public string Path => path; + public bool IsFirefox => isFirefox; + } } diff --git a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs index ee732bed9076..1542ddae9fd9 100644 --- a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs +++ b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs @@ -69,7 +69,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi return certificate; } - protected override void TrustCertificateCore(X509Certificate2 certificate) + protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) { using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); store.Open(OpenFlags.ReadWrite); @@ -77,7 +77,7 @@ protected override void TrustCertificateCore(X509Certificate2 certificate) if (TryFindCertificateInStore(store, certificate, out _)) { Log.WindowsCertificateAlreadyTrusted(); - return; + return TrustLevel.Full; } try @@ -87,6 +87,7 @@ protected override void TrustCertificateCore(X509Certificate2 certificate) using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); publicCertificate.FriendlyName = certificate.FriendlyName; store.Add(publicCertificate); + return TrustLevel.Full; } catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode) { @@ -114,10 +115,11 @@ protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certi Log.WindowsRemoveCertificateFromRootStoreEnd(); } - public override bool IsTrusted(X509Certificate2 certificate) + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) { - return ListCertificates(StoreName.Root, StoreLocation.CurrentUser, isValid: true, requireExportable: false) + var isTrusted = ListCertificates(StoreName.Root, StoreLocation.CurrentUser, isValid: true, requireExportable: false) .Any(c => AreCertificatesEqual(c, certificate)); + return isTrusted ? TrustLevel.Full : TrustLevel.None; } protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index 82e49d4d355a..3f386e630e0f 100644 --- a/src/Tools/dotnet-dev-certs/src/Program.cs +++ b/src/Tools/dotnet-dev-certs/src/Program.cs @@ -164,7 +164,7 @@ public static int Main(string[] args) if (check.HasValue()) { - return CheckHttpsCertificate(trust, reporter); + return CheckHttpsCertificate(trust, verbose, reporter); } if (clean.HasValue()) @@ -252,6 +252,12 @@ private static int CleanHttpsCertificates(IReporter reporter) reporter.Output("Cleaning HTTPS development certificates from the machine. This operation might " + "require elevated privileges. If that is the case, a prompt for credentials will be displayed."); } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + reporter.Output("Cleaning HTTPS development certificates from the machine. You may wish to update the " + + "SSL_CERT_DIR environment variable. " + + "See https://aka.ms/dev-certs-trust for more information."); + } manager.CleanupHttpsCertificates(); reporter.Output("HTTPS development certificates successfully removed from the machine."); @@ -266,7 +272,7 @@ private static int CleanHttpsCertificates(IReporter reporter) } } - private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter) + private static int CheckHttpsCertificate(CommandOption trust, CommandOption verbose, IReporter reporter) { var certificateManager = CertificateManager.Instance; var certificates = certificateManager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); @@ -295,32 +301,25 @@ private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter if (trust != null && trust.HasValue()) { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + var trustedCertificates = certificates.Where(cert => certificateManager.GetTrustLevel(cert) == CertificateManager.TrustLevel.Full).ToList(); + if (trustedCertificates.Count == 0) { - var trustedCertificates = certificates.Where(certificateManager.IsTrusted).ToList(); - if (!trustedCertificates.Any()) - { - reporter.Output($@"The following certificates were found, but none of them is trusted: {CertificateManager.ToCertificateDescription(certificates)}"); - return ErrorCertificateNotTrusted; - } - else + reporter.Output($@"The following certificates were found, but none of them is trusted: {CertificateManager.ToCertificateDescription(certificates)}"); + if (verbose == null || !verbose.HasValue()) { - ReportCertificates(reporter, trustedCertificates, "trusted"); + reporter.Output($@"Run the command with --verbose for more details."); } + return ErrorCertificateNotTrusted; } else { - reporter.Warn("Checking the HTTPS development certificate trust status was requested. Checking whether the certificate is trusted or not is not supported on Linux distributions." + - "For instructions on how to manually validate the certificate is trusted on your Linux distribution, go to https://aka.ms/dev-certs-trust"); + ReportCertificates(reporter, trustedCertificates, "trusted"); } } else { ReportCertificates(reporter, validCertificates, "valid"); - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - reporter.Output("Run the command with both --check and --trust options to ensure that the certificate is not only valid but also trusted."); - } + reporter.Output("Run the command with both --check and --trust options to ensure that the certificate is not only valid but also trusted."); } return Success; @@ -358,7 +357,9 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio } } - if (trust?.HasValue() == true) + var isTrustOptionSet = trust?.HasValue() == true; + + if (isTrustOptionSet) { if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { @@ -377,8 +378,9 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - reporter.Warn("Trusting the HTTPS development certificate was requested. Trusting the certificate on Linux distributions automatically is not supported. " + - "For instructions on how to manually trust the certificate on your Linux distribution, go to https://aka.ms/dev-certs-trust"); + reporter.Warn("Trusting the HTTPS development certificate was requested. " + + "Trust is per-user and may require additional configuration. " + + "See https://aka.ms/dev-certs-trust for more information."); } } @@ -393,7 +395,7 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio now, now.Add(HttpsCertificateValidity), exportPath.Value(), - trust == null ? false : trust.HasValue() && !RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + isTrustOptionSet, password.HasValue() || (noPassword.HasValue() && format == CertificateKeyExportFormat.Pem), password.Value(), exportFormat.HasValue() ? format : CertificateKeyExportFormat.Pfx); @@ -421,10 +423,14 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio reporter.Error("There was an error saving the HTTPS developer certificate to the current user personal certificate store."); return ErrorSavingTheCertificate; case EnsureCertificateResult.ErrorExportingTheCertificate: - reporter.Warn("There was an error exporting HTTPS developer certificate to a file."); + reporter.Warn("There was an error exporting the HTTPS developer certificate to a file."); return ErrorExportingTheCertificate; + case EnsureCertificateResult.PartiallyFailedToTrustTheCertificate: + // A distinct warning is useful, but a distinct error code is probably not. + reporter.Warn("There was an error trusting the HTTPS developer certificate. It will be trusted by some clients but not by others."); + return ErrorTrustingTheCertificate; case EnsureCertificateResult.FailedToTrustTheCertificate: - reporter.Warn("There was an error trusting HTTPS developer certificate."); + reporter.Warn("There was an error trusting the HTTPS developer certificate."); return ErrorTrustingTheCertificate; case EnsureCertificateResult.UserCancelledTrustStep: reporter.Warn("The user cancelled the trust step."); From 73221598e893ee871df7f73e8dea4f0d55537366 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 3 Jul 2024 12:04:01 -0700 Subject: [PATCH 02/12] Handle cert nickname collisions --- .../CertificateManager.cs | 11 ++++--- .../UnixCertificateManager.cs | 33 +++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index e39368f22133..7159ecbdf1b9 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -1195,14 +1195,17 @@ public sealed class CertificateManagerEventSource : EventSource [Event(105, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(105, certPath, exceptionMessage); - [Event(106, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + [Event(106, Level = EventLevel.Error, Message = "Unable to export the certificate since '{0}' already exists. Please remove it.")] + internal void UnixCertificateAlreadyExists(string certPath) => WriteEvent(106, certPath); + + [Event(107, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + "For example, `export SSL_CERT_DIR={0}:{1}`. " + "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(106, certDir, openSslDir, envVarName); + internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(107, certDir, openSslDir, envVarName); - [Event(107, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + [Event(108, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(107, certDir, envVarName); + internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(108, certDir, envVarName); } internal sealed class UserCancelledTrustException : Exception diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index 25d85dece2d4..91c3e9430fbd 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -155,9 +155,36 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) var nickname = GetCertificateNickname(certificate); var certPath = Path.Combine(certDir, nickname) + ".pem"; - // Security: we don't need the private key for trust, so we don't export it. - // Note that this will create directories as needed. - ExportCertificate(certificate, certPath, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); + var needToExport = true; + + // We do our own check for file collisions since ExportCertificate silently overwrites. + if (File.Exists(certPath)) + { + try + { + var existingCert = X509Certificate2.CreateFromPemFile(certPath); + if (!existingCert.RawDataMemory.Span.SequenceEqual(certificate.RawDataMemory.Span)) + { + Log.UnixCertificateAlreadyExists(certPath); + return TrustLevel.None; + } + + needToExport = false; // If the bits are on disk, we don't need to re-export + } + catch + { + // If we couldn't load the file, then we also can't safely overwite it. + Log.UnixCertificateAlreadyExists(certPath); + return TrustLevel.None; + } + } + + if (needToExport) + { + // Security: we don't need the private key for trust, so we don't export it. + // Note that this will create directories as needed. + ExportCertificate(certificate, certPath, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); + } // Once the certificate is on disk, we prefer not to throw - some subsequent trust step might succeed. From 17ba7e6b006b4c7cb50988c06c30b7f41be75777 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 3 Jul 2024 13:42:13 -0700 Subject: [PATCH 03/12] Forgot to hit save before committing --- src/Shared/CertificateGeneration/UnixCertificateManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index 91c3e9430fbd..64371a601ac6 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -162,7 +162,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) { try { - var existingCert = X509Certificate2.CreateFromPemFile(certPath); + var existingCert = new X509Certificate2(certPath); if (!existingCert.RawDataMemory.Span.SequenceEqual(certificate.RawDataMemory.Span)) { Log.UnixCertificateAlreadyExists(certPath); From a226497c236af666f7c39f5e6b1ec0254dceccfb Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 3 Jul 2024 13:45:13 -0700 Subject: [PATCH 04/12] Use the full thrumbprint as on mac --- src/Shared/CertificateGeneration/UnixCertificateManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index 64371a601ac6..660ced64bdfa 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -445,7 +445,7 @@ private static HashSet FindAvailableCommands() private static string GetCertificateNickname(X509Certificate2 certificate) { - return $"aspnetcore-localhost-{certificate.Thumbprint[0..6]}"; + return $"aspnetcore-localhost-{certificate.Thumbprint}"; } /// From ed80e60f459c70751ada697472898d4e4a98736a Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 3 Jul 2024 14:22:48 -0700 Subject: [PATCH 05/12] Delete some dead mac-specific code --- src/Servers/Kestrel/Core/src/KestrelServerOptions.cs | 2 +- src/Shared/CertificateGeneration/CertificateManager.cs | 4 ++-- .../CertificateGeneration/MacOSCertificateManager.cs | 7 +------ src/Shared/CertificateGeneration/UnixCertificateManager.cs | 2 +- .../CertificateGeneration/WindowsCertificateManager.cs | 2 +- src/Tools/dotnet-dev-certs/src/Program.cs | 4 ++-- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index bd05af118ee0..1bba418d3962 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -384,7 +384,7 @@ internal void Serialize(Utf8JsonWriter writer) return null; } - var status = CertificateManager.Instance.CheckCertificateState(cert, interactive: false); + var status = CertificateManager.Instance.CheckCertificateState(cert); if (!status.Success) { // Display a warning indicating to the user that a prompt might appear and provide instructions on what to do in that diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index 7159ecbdf1b9..fb113e73162e 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -208,7 +208,7 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( // as we don't want to prompt on first run experience. foreach (var candidate in currentUserCertificates) { - var status = CheckCertificateState(candidate, true); + var status = CheckCertificateState(candidate); if (!status.Success) { try @@ -735,7 +735,7 @@ internal void RemoveCertificate(X509Certificate2 certificate, RemoveLocations lo } } - internal abstract CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive); + internal abstract CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate); internal abstract void CorrectCertificateState(X509Certificate2 candidate); diff --git a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs index cf792a904b89..b8c16b50c9a4 100644 --- a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs @@ -71,11 +71,6 @@ internal sealed class MacOSCertificateManager : CertificateManager "and create a new untrusted developer certificate. " + "Use 'dotnet dev-certs https --trust' to trust the new certificate."; - public const string KeyNotAccessibleWithoutUserInteraction = - "The application is trying to access the ASP.NET Core developer certificate key. " + - "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."; - public MacOSCertificateManager() { } @@ -128,7 +123,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertif } } - internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive) + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) { return File.Exists(GetCertificateFilePath(candidate)) ? new CheckCertificateStateResult(true, null) : diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index 660ced64bdfa..37ee06134613 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -127,7 +127,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi return certificate; } - internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive) + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) { // Return true as we don't perform any check. // This is about checking storage, not trust. diff --git a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs index 1542ddae9fd9..058fb7fef023 100644 --- a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs +++ b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs @@ -39,7 +39,7 @@ protected override bool IsExportable(X509Certificate2 c) #endif } - internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive) + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) { return new CheckCertificateStateResult(true, null); } diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index 3f386e630e0f..9f5407d9d3e4 100644 --- a/src/Tools/dotnet-dev-certs/src/Program.cs +++ b/src/Tools/dotnet-dev-certs/src/Program.cs @@ -289,7 +289,7 @@ private static int CheckHttpsCertificate(CommandOption trust, CommandOption verb // We never want check to require interaction. // When IDEs run dotnet dev-certs https after calling --check, we will try to access the key and // that will trigger a prompt if necessary. - var status = certificateManager.CheckCertificateState(certificate, interactive: false); + var status = certificateManager.CheckCertificateState(certificate); if (!status.Success) { reporter.Warn(status.FailureMessage); @@ -344,7 +344,7 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio var certificates = manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, exportPath.HasValue()); foreach (var certificate in certificates) { - var status = manager.CheckCertificateState(certificate, interactive: true); + var status = manager.CheckCertificateState(certificate); if (!status.Success) { reporter.Warn("One or more certificates might be in an invalid state. We will try to access the certificate key " + From c89b7d46362a35e3b3195304ddc8db2c074e9436 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 3 Jul 2024 17:03:37 -0700 Subject: [PATCH 06/12] Use better path for trust roots --- src/Shared/CertificateGeneration/UnixCertificateManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index 37ee06134613..509ca53f761c 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -565,7 +565,7 @@ private static string GetOpenSslCertificateDirectory(string homeDirectory) return @override; } - return Path.Combine(homeDirectory, ".dotnet", "corefx", "cryptography", "trusted"); + return Path.Combine(homeDirectory, ".aspnet", "dev-certs", "trust"); } private static bool TryDeleteCertificateFile(string certPath) From 48da7601d93435f3f68e255f619b2ddba05ea8d1 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 9 Jul 2024 11:52:17 -0700 Subject: [PATCH 07/12] Also trust certificates in the Current User/Root store A belt-and-suspenders approach for dotnet trust (i.e. in addition to OpenSSL trust) that has the notable advantage of not requiring any environment variables. --- .../CertificateManager.cs | 106 +++++++++--------- .../UnixCertificateManager.cs | 102 ++++++++++++++--- 2 files changed, 144 insertions(+), 64 deletions(-) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index fb113e73162e..d02027d32eb4 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -1090,11 +1090,11 @@ public sealed class CertificateManagerEventSource : EventSource [Event(74, Level = EventLevel.Warning, Message = "The NSS database '{0}' provided via {1} does not exist.")] internal void UnixNssDbDoesNotExist(string nssDb, string environmentVariable) => WriteEvent(74, nssDb, environmentVariable); - [Event(75, Level = EventLevel.Warning, Message = "The certificate is not trusted by OpenSSL. This will likely affect System.Net.Http.HttpClient.")] - internal void UnixNotTrustedByOpenSsl() => WriteEvent(75); + [Event(75, Level = EventLevel.Warning, Message = "The certificate is not trusted by .NET. This will likely affect System.Net.Http.HttpClient.")] + internal void UnixNotTrustedByDotnet() => WriteEvent(75); - [Event(76, Level = EventLevel.Warning, Message = "The certificate is not trusted by OpenSSL. Ensure that the {0} environment variable is set correctly. This will likely affect System.Net.Http.HttpClient.")] - internal void UnixNotTrustedByOpenSslVariableUnset(string envVarName) => WriteEvent(76, envVarName); + [Event(76, Level = EventLevel.Warning, Message = "The certificate is not trusted by OpenSSL. Ensure that the {0} environment variable is set correctly.")] + internal void UnixNotTrustedByOpenSsl(string envVarName) => WriteEvent(76, envVarName); [Event(77, Level = EventLevel.Warning, Message = "The certificate is not trusted in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] internal void UnixNotTrustedByNss(string path, string browser) => WriteEvent(77, path, browser); @@ -1132,80 +1132,86 @@ public sealed class CertificateManagerEventSource : EventSource "Manually rehashing may help. See https://aka.ms/dev-certs-trust for more information.")] // This should recommend manually running c_rehash. internal void UnixOpenSslRehashException(string exceptionMessage) => WriteEvent(85, exceptionMessage); - [Event(86, Level = EventLevel.Warning, Message = "Clients that validate certificate trust using OpenSSL, including System.Net.Http.HttpClient, will not trust the certificate.")] - internal void UnixOpenSslTrustFailed() => WriteEvent(86); + [Event(86, Level = EventLevel.Warning, Message = "Failed to trust the certificate in .NET: {0}.")] + internal void UnixDotnetTrustException(string exceptionMessage) => WriteEvent(86, exceptionMessage); - [Event(87, Level = EventLevel.Verbose, Message = "Trusted the certificate in OpenSSL.")] - internal void UnixOpenSslTrustSucceeded() => WriteEvent(87); + [Event(87, Level = EventLevel.Warning, Message = "Clients that validate certificate trust using OpenSSL will not trust the certificate.")] + internal void UnixOpenSslTrustFailed() => WriteEvent(87); - [Event(88, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] - internal void UnixNssDbTrustFailed(string path, string browser) => WriteEvent(88, path, browser); + [Event(88, Level = EventLevel.Verbose, Message = "Trusted the certificate in OpenSSL.")] + internal void UnixOpenSslTrustSucceeded() => WriteEvent(88); - [Event(89, Level = EventLevel.Verbose, Message = "Trusted the certificate in the NSS database in '{0}'.")] - internal void UnixNssDbTrustSucceeded(string path) => WriteEvent(89, path); + [Event(89, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] + internal void UnixNssDbTrustFailed(string path, string browser) => WriteEvent(89, path, browser); - [Event(90, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in OpenSSL.")] - internal void UnixOpenSslUntrustFailed() => WriteEvent(90); + [Event(90, Level = EventLevel.Verbose, Message = "Trusted the certificate in the NSS database in '{0}'.")] + internal void UnixNssDbTrustSucceeded(string path) => WriteEvent(90, path); - [Event(91, Level = EventLevel.Verbose, Message = "Untrusted the certificate in OpenSSL.")] - internal void UnixOpenSslUntrustSucceeded() => WriteEvent(91); + [Event(91, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in .NET: {0}.")] + internal void UnixDotnetUntrustException(string exceptionMessage) => WriteEvent(91, exceptionMessage); - [Event(92, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}'.")] - internal void UnixNssDbUntrustFailed(string path) => WriteEvent(92, path); + [Event(92, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustFailed() => WriteEvent(92); - [Event(93, Level = EventLevel.Verbose, Message = "Removed the certificate from the NSS database in '{0}'.")] - internal void UnixNssDbUntrustSucceeded(string path) => WriteEvent(93, path); + [Event(93, Level = EventLevel.Verbose, Message = "Untrusted the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustSucceeded() => WriteEvent(93); - [Event(94, Level = EventLevel.Warning, Message = "The certificate is only partially trusted - some clients will not accept it.")] - internal void UnixTrustPartiallySucceeded() => WriteEvent(94); + [Event(94, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}'.")] + internal void UnixNssDbUntrustFailed(string path) => WriteEvent(94, path); - [Event(95, Level = EventLevel.Warning, Message = "Failed to look up the certificate in the NSS database in '{0}': {1}.")] - internal void UnixNssDbCheckException(string path, string exceptionMessage) => WriteEvent(95, path, exceptionMessage); + [Event(95, Level = EventLevel.Verbose, Message = "Removed the certificate from the NSS database in '{0}'.")] + internal void UnixNssDbUntrustSucceeded(string path) => WriteEvent(95, path); - [Event(96, Level = EventLevel.Warning, Message = "Failed to add the certificate to the NSS database in '{0}': {1}.")] - internal void UnixNssDbAdditionException(string path, string exceptionMessage) => WriteEvent(96, path, exceptionMessage); + [Event(96, Level = EventLevel.Warning, Message = "The certificate is only partially trusted - some clients will not accept it.")] + internal void UnixTrustPartiallySucceeded() => WriteEvent(96); - [Event(97, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}': {1}.")] - internal void UnixNssDbRemovalException(string path, string exceptionMessage) => WriteEvent(97, path, exceptionMessage); + [Event(97, Level = EventLevel.Warning, Message = "Failed to look up the certificate in the NSS database in '{0}': {1}.")] + internal void UnixNssDbCheckException(string path, string exceptionMessage) => WriteEvent(97, path, exceptionMessage); - [Event(98, Level = EventLevel.Warning, Message = "Failed to find the Firefox profiles in directory '{0}': {1}.")] - internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => WriteEvent(98, firefoxDirectory, message); + [Event(98, Level = EventLevel.Warning, Message = "Failed to add the certificate to the NSS database in '{0}': {1}.")] + internal void UnixNssDbAdditionException(string path, string exceptionMessage) => WriteEvent(98, path, exceptionMessage); - [Event(99, Level = EventLevel.Verbose, Message = "No Firefox profiles found in directory '{0}'.")] - internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => WriteEvent(99, firefoxDirectory); + [Event(99, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}': {1}.")] + internal void UnixNssDbRemovalException(string path, string exceptionMessage) => WriteEvent(99, path, exceptionMessage); - [Event(100, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers. " + + [Event(100, Level = EventLevel.Warning, Message = "Failed to find the Firefox profiles in directory '{0}': {1}.")] + internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => WriteEvent(100, firefoxDirectory, message); + + [Event(101, Level = EventLevel.Verbose, Message = "No Firefox profiles found in directory '{0}'.")] + internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => WriteEvent(101, firefoxDirectory); + + [Event(102, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers. " + "This likely indicates that the database already contains an entry for the certificate under a different name. Please remove it and try again.")] - internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => WriteEvent(100, path, browser); + internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => WriteEvent(102, path, browser); // This may be annoying, since anyone setting the variable for un/trust will likely leave it set for --check. // However, it seems important to warn users who set it specifically for --check. - [Event(101, Level = EventLevel.Warning, Message = "The {0} environment variable is set but will not be consumed while checking trust.")] - internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => WriteEvent(101, openSslCertDirectoryOverrideVariableName); + [Event(103, Level = EventLevel.Warning, Message = "The {0} environment variable is set but will not be consumed while checking trust.")] + internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => WriteEvent(103, openSslCertDirectoryOverrideVariableName); - [Event(102, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for updating certificate trust in OpenSSL, which is used by System.Net.Http.HttpClient.")] - internal void UnixMissingOpenSslCommand(string openSslCommand) => WriteEvent(102, openSslCommand); + [Event(104, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for updating certificate trust in OpenSSL.")] + internal void UnixMissingOpenSslCommand(string openSslCommand) => WriteEvent(104, openSslCommand); - [Event(103, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.")] - internal void UnixMissingCertUtilCommand(string certUtilCommand) => WriteEvent(103, certUtilCommand); + [Event(105, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.")] + internal void UnixMissingCertUtilCommand(string certUtilCommand) => WriteEvent(105, certUtilCommand); - [Event(104, Level = EventLevel.Verbose, Message = "Untrusting the certificate in OpenSSL was skipped since '{0}' does not exist.")] - internal void UnixOpenSslUntrustSkipped(string certPath) => WriteEvent(104, certPath); + [Event(106, Level = EventLevel.Verbose, Message = "Untrusting the certificate in OpenSSL was skipped since '{0}' does not exist.")] + internal void UnixOpenSslUntrustSkipped(string certPath) => WriteEvent(106, certPath); - [Event(105, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] - internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(105, certPath, exceptionMessage); + [Event(107, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] + internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(107, certPath, exceptionMessage); - [Event(106, Level = EventLevel.Error, Message = "Unable to export the certificate since '{0}' already exists. Please remove it.")] - internal void UnixCertificateAlreadyExists(string certPath) => WriteEvent(106, certPath); + [Event(108, Level = EventLevel.Error, Message = "Unable to export the certificate since '{0}' already exists. Please remove it.")] + internal void UnixNotOverwritingCertificate(string certPath) => WriteEvent(108, certPath); - [Event(107, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + [Event(109, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + "For example, `export SSL_CERT_DIR={0}:{1}`. " + "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(107, certDir, openSslDir, envVarName); + internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(109, certDir, openSslDir, envVarName); - [Event(108, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + [Event(110, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(108, certDir, envVarName); + internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(110, certDir, envVarName); } internal sealed class UserCancelledTrustException : Exception diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index 509ca53f761c..475cc3cb25c4 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -11,6 +11,13 @@ namespace Microsoft.AspNetCore.Certificates.Generation; +/// +/// On Unix, we trust the certificate in the following locations: +/// 1. dotnet (i.e. the CurrentUser/Root store) +/// 2. OpenSSL (i.e. adding it to a directory in $SSL_CERT_DIR) +/// 3. Firefox & Chromium (i.e. adding it to an NSS DB for each browser) +/// All of these locations are per-user. +/// internal sealed partial class UnixCertificateManager : CertificateManager { /// The name of an environment variable consumed by OpenSSL to locate certificates. @@ -50,8 +57,8 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate) Log.UnixOpenSslCertificateDirectoryOverrideIgnored(OpenSslCertDirectoryOverrideVariableName); } - // Building the chain will check whether openssl (which covers HttpClient) trusts the cert. - // An alternative approach would be to look for the file and link in the trust folder, but + // Building the chain will check whether dotnet trusts the cert. We could, instead, + // enumerate the Root store and/or look for the file in the OpenSSL directory, but // this tests the real-world behavior. using var chain = new X509Chain(); // This is just a heuristic for whether or not we should prompt the user to re-run with `--trust` @@ -64,14 +71,43 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate) else { sawTrustFailure = true; + Log.UnixNotTrustedByDotnet(); + } + + var nickname = GetCertificateNickname(certificate); + + var sslCertDirString = Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName); + if (string.IsNullOrEmpty(sslCertDirString)) + { + sawTrustFailure = true; + Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); + } + else + { + var foundCert = false; + var sslCertDirs = sslCertDirString.Split(Path.PathSeparator); + foreach (var sslCertDir in sslCertDirs) + { + var certPath = Path.Combine(sslCertDir, nickname + ".pem"); + if (File.Exists(certPath)) + { + var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath); + if (AreCertificatesEqual(certificate, candidate)) + { + foundCert = true; + break; + } + } + } - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName))) + if (foundCert) { - Log.UnixNotTrustedByOpenSslVariableUnset(OpenSslCertificateDirectoryVariableName); + sawTrustSuccess = true; } else { - Log.UnixNotTrustedByOpenSsl(); + sawTrustFailure = true; + Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); } } @@ -87,7 +123,6 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate) } else { - var nickname = GetCertificateNickname(certificate); foreach (var nssDb in nssDbs) { if (IsCertificateInNssDb(nickname, nssDb)) @@ -144,6 +179,32 @@ internal override void CorrectCertificateState(X509Certificate2 candidate) protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) { + var sawTrustFailure = false; + var sawTrustSuccess = false; + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out _)) + { + sawTrustSuccess = true; + } + else + { + try + { + using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); + // FriendlyName is Windows-only, so we don't set it here. + store.Add(publicCertificate); + sawTrustSuccess = true; + } + catch (Exception ex) + { + sawTrustFailure = true; + Log.UnixDotnetTrustException(ex.Message); + } + } + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); // Rather than create a temporary file we'll have to clean up, we prefer to export the dev cert @@ -162,10 +223,10 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) { try { - var existingCert = new X509Certificate2(certPath); - if (!existingCert.RawDataMemory.Span.SequenceEqual(certificate.RawDataMemory.Span)) + using var existingCert = X509CertificateLoader.LoadCertificateFromFile(certPath); + if (!AreCertificatesEqual(existingCert, certificate)) { - Log.UnixCertificateAlreadyExists(certPath); + Log.UnixNotOverwritingCertificate(certPath); return TrustLevel.None; } @@ -174,7 +235,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) catch { // If we couldn't load the file, then we also can't safely overwite it. - Log.UnixCertificateAlreadyExists(certPath); + Log.UnixNotOverwritingCertificate(certPath); return TrustLevel.None; } } @@ -188,9 +249,6 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) // Once the certificate is on disk, we prefer not to throw - some subsequent trust step might succeed. - var sawTrustFailure = false; - var sawTrustSuccess = false; - var openSslTrustSucceeded = false; var isOpenSslAvailable = IsCommandAvailable(OpenSslCommand); @@ -297,9 +355,25 @@ protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certi { var sawUntrustFailure = false; + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out var matching)) + { + try + { + store.Remove(matching); + } + catch (Exception ex) + { + Log.UnixDotnetUntrustException(ex.Message); + sawUntrustFailure = true; + } + } + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)!; - // We don't attempt to clean this up when it's empty - it's a standard location + // We don't attempt to remove the directory when it's empty - it's a standard location // and will almost certainly be used in the future. var certDir = GetOpenSslCertificateDirectory(homeDirectory); // May not exist From 6ae6f65e831a010b504996e8734adfff09078ae0 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 10 Jul 2024 12:23:10 -0700 Subject: [PATCH 08/12] Log dotnet trust success --- .../CertificateManager.cs | 99 ++++++++++--------- .../UnixCertificateManager.cs | 1 + 2 files changed, 52 insertions(+), 48 deletions(-) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index d02027d32eb4..bed277afb5ca 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -1135,83 +1135,86 @@ public sealed class CertificateManagerEventSource : EventSource [Event(86, Level = EventLevel.Warning, Message = "Failed to trust the certificate in .NET: {0}.")] internal void UnixDotnetTrustException(string exceptionMessage) => WriteEvent(86, exceptionMessage); - [Event(87, Level = EventLevel.Warning, Message = "Clients that validate certificate trust using OpenSSL will not trust the certificate.")] - internal void UnixOpenSslTrustFailed() => WriteEvent(87); + [Event(87, Level = EventLevel.Verbose, Message = "Trusted the certificate in .NET.")] + internal void UnixDotnetTrustSucceeded() => WriteEvent(87); - [Event(88, Level = EventLevel.Verbose, Message = "Trusted the certificate in OpenSSL.")] - internal void UnixOpenSslTrustSucceeded() => WriteEvent(88); + [Event(88, Level = EventLevel.Warning, Message = "Clients that validate certificate trust using OpenSSL will not trust the certificate.")] + internal void UnixOpenSslTrustFailed() => WriteEvent(88); - [Event(89, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] - internal void UnixNssDbTrustFailed(string path, string browser) => WriteEvent(89, path, browser); + [Event(89, Level = EventLevel.Verbose, Message = "Trusted the certificate in OpenSSL.")] + internal void UnixOpenSslTrustSucceeded() => WriteEvent(89); - [Event(90, Level = EventLevel.Verbose, Message = "Trusted the certificate in the NSS database in '{0}'.")] - internal void UnixNssDbTrustSucceeded(string path) => WriteEvent(90, path); + [Event(90, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] + internal void UnixNssDbTrustFailed(string path, string browser) => WriteEvent(90, path, browser); - [Event(91, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in .NET: {0}.")] - internal void UnixDotnetUntrustException(string exceptionMessage) => WriteEvent(91, exceptionMessage); + [Event(91, Level = EventLevel.Verbose, Message = "Trusted the certificate in the NSS database in '{0}'.")] + internal void UnixNssDbTrustSucceeded(string path) => WriteEvent(91, path); - [Event(92, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in OpenSSL.")] - internal void UnixOpenSslUntrustFailed() => WriteEvent(92); + [Event(92, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in .NET: {0}.")] + internal void UnixDotnetUntrustException(string exceptionMessage) => WriteEvent(92, exceptionMessage); - [Event(93, Level = EventLevel.Verbose, Message = "Untrusted the certificate in OpenSSL.")] - internal void UnixOpenSslUntrustSucceeded() => WriteEvent(93); + [Event(93, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustFailed() => WriteEvent(93); - [Event(94, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}'.")] - internal void UnixNssDbUntrustFailed(string path) => WriteEvent(94, path); + [Event(94, Level = EventLevel.Verbose, Message = "Untrusted the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustSucceeded() => WriteEvent(94); - [Event(95, Level = EventLevel.Verbose, Message = "Removed the certificate from the NSS database in '{0}'.")] - internal void UnixNssDbUntrustSucceeded(string path) => WriteEvent(95, path); + [Event(95, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}'.")] + internal void UnixNssDbUntrustFailed(string path) => WriteEvent(95, path); - [Event(96, Level = EventLevel.Warning, Message = "The certificate is only partially trusted - some clients will not accept it.")] - internal void UnixTrustPartiallySucceeded() => WriteEvent(96); + [Event(96, Level = EventLevel.Verbose, Message = "Removed the certificate from the NSS database in '{0}'.")] + internal void UnixNssDbUntrustSucceeded(string path) => WriteEvent(96, path); - [Event(97, Level = EventLevel.Warning, Message = "Failed to look up the certificate in the NSS database in '{0}': {1}.")] - internal void UnixNssDbCheckException(string path, string exceptionMessage) => WriteEvent(97, path, exceptionMessage); + [Event(97, Level = EventLevel.Warning, Message = "The certificate is only partially trusted - some clients will not accept it.")] + internal void UnixTrustPartiallySucceeded() => WriteEvent(97); - [Event(98, Level = EventLevel.Warning, Message = "Failed to add the certificate to the NSS database in '{0}': {1}.")] - internal void UnixNssDbAdditionException(string path, string exceptionMessage) => WriteEvent(98, path, exceptionMessage); + [Event(98, Level = EventLevel.Warning, Message = "Failed to look up the certificate in the NSS database in '{0}': {1}.")] + internal void UnixNssDbCheckException(string path, string exceptionMessage) => WriteEvent(98, path, exceptionMessage); - [Event(99, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}': {1}.")] - internal void UnixNssDbRemovalException(string path, string exceptionMessage) => WriteEvent(99, path, exceptionMessage); + [Event(99, Level = EventLevel.Warning, Message = "Failed to add the certificate to the NSS database in '{0}': {1}.")] + internal void UnixNssDbAdditionException(string path, string exceptionMessage) => WriteEvent(99, path, exceptionMessage); - [Event(100, Level = EventLevel.Warning, Message = "Failed to find the Firefox profiles in directory '{0}': {1}.")] - internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => WriteEvent(100, firefoxDirectory, message); + [Event(100, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}': {1}.")] + internal void UnixNssDbRemovalException(string path, string exceptionMessage) => WriteEvent(100, path, exceptionMessage); - [Event(101, Level = EventLevel.Verbose, Message = "No Firefox profiles found in directory '{0}'.")] - internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => WriteEvent(101, firefoxDirectory); + [Event(101, Level = EventLevel.Warning, Message = "Failed to find the Firefox profiles in directory '{0}': {1}.")] + internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => WriteEvent(101, firefoxDirectory, message); - [Event(102, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers. " + + [Event(102, Level = EventLevel.Verbose, Message = "No Firefox profiles found in directory '{0}'.")] + internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => WriteEvent(102, firefoxDirectory); + + [Event(103, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers. " + "This likely indicates that the database already contains an entry for the certificate under a different name. Please remove it and try again.")] - internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => WriteEvent(102, path, browser); + internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => WriteEvent(103, path, browser); // This may be annoying, since anyone setting the variable for un/trust will likely leave it set for --check. // However, it seems important to warn users who set it specifically for --check. - [Event(103, Level = EventLevel.Warning, Message = "The {0} environment variable is set but will not be consumed while checking trust.")] - internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => WriteEvent(103, openSslCertDirectoryOverrideVariableName); + [Event(104, Level = EventLevel.Warning, Message = "The {0} environment variable is set but will not be consumed while checking trust.")] + internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => WriteEvent(104, openSslCertDirectoryOverrideVariableName); - [Event(104, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for updating certificate trust in OpenSSL.")] - internal void UnixMissingOpenSslCommand(string openSslCommand) => WriteEvent(104, openSslCommand); + [Event(105, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for updating certificate trust in OpenSSL.")] + internal void UnixMissingOpenSslCommand(string openSslCommand) => WriteEvent(105, openSslCommand); - [Event(105, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.")] - internal void UnixMissingCertUtilCommand(string certUtilCommand) => WriteEvent(105, certUtilCommand); + [Event(106, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.")] + internal void UnixMissingCertUtilCommand(string certUtilCommand) => WriteEvent(106, certUtilCommand); - [Event(106, Level = EventLevel.Verbose, Message = "Untrusting the certificate in OpenSSL was skipped since '{0}' does not exist.")] - internal void UnixOpenSslUntrustSkipped(string certPath) => WriteEvent(106, certPath); + [Event(107, Level = EventLevel.Verbose, Message = "Untrusting the certificate in OpenSSL was skipped since '{0}' does not exist.")] + internal void UnixOpenSslUntrustSkipped(string certPath) => WriteEvent(107, certPath); - [Event(107, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] - internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(107, certPath, exceptionMessage); + [Event(108, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] + internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(108, certPath, exceptionMessage); - [Event(108, Level = EventLevel.Error, Message = "Unable to export the certificate since '{0}' already exists. Please remove it.")] - internal void UnixNotOverwritingCertificate(string certPath) => WriteEvent(108, certPath); + [Event(109, Level = EventLevel.Error, Message = "Unable to export the certificate since '{0}' already exists. Please remove it.")] + internal void UnixNotOverwritingCertificate(string certPath) => WriteEvent(109, certPath); - [Event(109, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + [Event(110, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + "For example, `export SSL_CERT_DIR={0}:{1}`. " + "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(109, certDir, openSslDir, envVarName); + internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(110, certDir, openSslDir, envVarName); - [Event(110, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + [Event(111, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(110, certDir, envVarName); + internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(111, certDir, envVarName); } internal sealed class UserCancelledTrustException : Exception diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index 475cc3cb25c4..6fa154bb8e93 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -196,6 +196,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); // FriendlyName is Windows-only, so we don't set it here. store.Add(publicCertificate); + Log.UnixDotnetTrustSucceeded(); sawTrustSuccess = true; } catch (Exception ex) From 71eb839f87e28480b2a5f3482f8af5c294338657 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 10 Jul 2024 13:47:55 -0700 Subject: [PATCH 09/12] Improve confusing variable name --- src/Shared/CertificateGeneration/CertificateManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index bed277afb5ca..c9092d7ad931 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -182,8 +182,8 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( var result = EnsureCertificateResult.Succeeded; var currentUserCertificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true); - var trustedCertificates = ListCertificates(StoreName.My, StoreLocation.LocalMachine, isValid: true, requireExportable: true); - var certificates = currentUserCertificates.Concat(trustedCertificates); + var localMachineCertificates = ListCertificates(StoreName.My, StoreLocation.LocalMachine, isValid: true, requireExportable: true); + var certificates = currentUserCertificates.Concat(localMachineCertificates); var filteredCertificates = certificates.Where(c => c.Subject == Subject); From 7b92ec8e5a23568cfaad8662453206b7aba9a034 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 10 Jul 2024 14:11:04 -0700 Subject: [PATCH 10/12] Tests should also clean up Current User\Root on Linux --- src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs index afe4c872ac8f..e93a6f95b00e 100644 --- a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs +++ b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs @@ -468,7 +468,7 @@ public CertFixture() internal void CleanupCertificates() { Manager.RemoveAllCertificates(StoreName.My, StoreLocation.CurrentUser); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { Manager.RemoveAllCertificates(StoreName.Root, StoreLocation.CurrentUser); } From 6da7a95e528394541b9ec4e93c58f45048de55af Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 10 Jul 2024 15:26:08 -0700 Subject: [PATCH 11/12] Drop unnecessary `virtual` --- src/Shared/CertificateGeneration/CertificateManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index c9092d7ad931..e42936ade88b 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -782,7 +782,7 @@ internal static void DisposeCertificates(IEnumerable disposabl } } - protected virtual void RemoveCertificateFromUserStore(X509Certificate2 certificate) + protected void RemoveCertificateFromUserStore(X509Certificate2 certificate) { try { From 1ae6a36229a14a61814edbeabde7dbadb690b27b Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 10 Jul 2024 17:07:07 -0700 Subject: [PATCH 12/12] Clarify the mac-specific comments in GetDevelopmentCertificateFromStore --- .../Kestrel/Core/src/KestrelServerOptions.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index 1bba418d3962..63efe1767cad 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -387,10 +387,13 @@ internal void Serialize(Utf8JsonWriter writer) var status = CertificateManager.Instance.CheckCertificateState(cert); if (!status.Success) { - // Display a warning indicating to the user that a prompt might appear and provide instructions on what to do in that - // case. The underlying implementation of this check is specific to Mac OS and is handled within CheckCertificateState. - // Kestrel must NEVER cause a UI prompt on a production system. We only attempt this here because Mac OS is not supported - // in production. + // Failure is only possible on MacOS and indicates that, if there is a dev cert, it must be from + // a dotnet version prior to 7.0 - newer versions store it in such a way that this check succeeds. + // (Success does not mean that the dev cert has been trusted). + // In practice, success.FailureMessage will always be MacOSCertificateManager.InvalidCertificateState. + // Basically, we're just going to encourage the user to generate and trust the dev cert. We support + // these older certificates not by accepting them as-is, but by modernizing them when dev-certs is run. + // If we detect an issue here, we can avoid a UI prompt below. Debug.Assert(status.FailureMessage != null, "Status with a failure result must have a message."); logger.DeveloperCertificateFirstRun(status.FailureMessage); @@ -398,6 +401,9 @@ internal void Serialize(Utf8JsonWriter writer) return null; } + // On MacOS, this may cause a UI prompt, since it requires accessing the keychain. Kestrel must NEVER + // cause a UI prompt on a production system. We only attempt this here because MacOS is not supported + // in production. switch (CertificateManager.Instance.GetTrustLevel(cert)) { case CertificateManager.TrustLevel.Partial: