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..63efe1767cad 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -384,13 +384,16 @@ 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 - // 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,9 +401,17 @@ internal void Serialize(Utf8JsonWriter writer) return null; } - if (!CertificateManager.Instance.IsTrusted(cert)) + // 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)) { - 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 e3b1526eb80b..eb16e6f51b54 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -194,8 +194,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); @@ -220,7 +220,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 @@ -345,7 +345,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) { @@ -458,11 +471,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); @@ -680,7 +694,7 @@ internal X509Certificate2 SaveCertificate(X509Certificate2 certificate) return certificate; } - internal void TrustCertificate(X509Certificate2 certificate) + internal TrustLevel TrustCertificate(X509Certificate2 certificate) { try { @@ -688,8 +702,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()) { @@ -735,7 +750,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); @@ -782,7 +797,7 @@ internal static void DisposeCertificates(IEnumerable disposabl } } - protected virtual void RemoveCertificateFromUserStore(X509Certificate2 certificate) + protected void RemoveCertificateFromUserStore(X509Certificate2 certificate) { try { @@ -871,6 +886,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 { @@ -921,7 +939,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.")] @@ -1076,6 +1094,142 @@ 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 .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.")] + 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); + + // 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 = "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 .NET.")] + internal void UnixDotnetTrustSucceeded() => WriteEvent(87); + + [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.Verbose, Message = "Trusted the certificate in OpenSSL.")] + internal void UnixOpenSslTrustSucceeded() => WriteEvent(89); + + [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.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 .NET: {0}.")] + internal void UnixDotnetUntrustException(string exceptionMessage) => WriteEvent(92, exceptionMessage); + + [Event(93, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustFailed() => WriteEvent(93); + + [Event(94, Level = EventLevel.Verbose, Message = "Untrusted the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustSucceeded() => WriteEvent(94); + + [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.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 = "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 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 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 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.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.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(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(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(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(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(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(108, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] + internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(108, certPath, exceptionMessage); + + [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(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(110, certDir, openSslDir, envVarName); + + [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(111, certDir, envVarName); } internal sealed class UserCancelledTrustException : Exception @@ -1101,4 +1255,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..b8c16b50c9a4 100644 --- a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs @@ -69,12 +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."; - - 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."; + "Use 'dotnet dev-certs https --trust' to trust the new certificate."; public MacOSCertificateManager() { @@ -85,12 +80,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 +108,7 @@ protected override void TrustCertificateCore(X509Certificate2 publicCertificate) } } Log.MacOSTrustCommandEnd(); + return TrustLevel.Full; } finally { @@ -125,7 +123,7 @@ protected override void TrustCertificateCore(X509Certificate2 publicCertificate) } } - internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive) + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) { return File.Exists(GetCertificateFilePath(candidate)) ? new CheckCertificateStateResult(true, null) : @@ -149,7 +147,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 +164,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..6fa154bb8e93 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -1,14 +1,42 @@ // 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 +/// +/// 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. + 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 +46,103 @@ 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 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` // 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; + 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 (foundCert) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); + } + } + + 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 + { + 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) @@ -44,29 +162,750 @@ 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. 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 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); + Log.UnixDotnetTrustSucceeded(); + 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 + // 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"; + + var needToExport = true; + + // We do our own check for file collisions since ExportCertificate silently overwrites. + if (File.Exists(certPath)) + { + try + { + using var existingCert = X509CertificateLoader.LoadCertificateFromFile(certPath); + if (!AreCertificatesEqual(existingCert, certificate)) + { + Log.UnixNotOverwritingCertificate(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.UnixNotOverwritingCertificate(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. + + 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; + + 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 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 + + 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}"; + } + + /// + /// 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, ".aspnet", "dev-certs", "trust"); + } + + 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..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); } @@ -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/FirstRunCertGenerator/test/CertificateManagerTests.cs b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs index 6ea15546072b..e6c81b006a56 100644 --- a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs +++ b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs @@ -509,7 +509,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); } diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index 82e49d4d355a..9f5407d9d3e4 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); @@ -283,7 +289,7 @@ private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter // 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); @@ -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; @@ -345,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 " + @@ -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.");