From 32e5f4ff92f9439392ac233c4abe224ebad25d8c Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 8 Aug 2024 12:35:49 -0700 Subject: [PATCH] [8.0] Add support for trusting dev certs on linux (#57108) * Look up trusted certs consistently on windows (#56701) * Search for trusted certificates consistently on Windows 1. Don't use thumbprint so we don't get flagged for using SHA-1 2. Make TrustCertificateCore and RemoveCertificateFromTrustedRoots consistent * Add a note about our usage of Thumbprint on macOS * Clean up assumptions about root store * FindBySubjectName expects a string * Search by serial number to avoid having to parse subject name * Fix typo Co-authored-by: Martin Costello * Call DisposeCertificates more consistently --------- Co-authored-by: Martin Costello (cherry picked from commit ed7ea4042be01dad1d45397173df5a3738a2e650) * Make dev-certs import consistent with kestrel (#57014) * Make dev-certs import consistent with kestrel Kestrel checks the subject name and our magic extension - import was only checking the extension. They can't easily share a method because import has a test hook. (cherry picked from commit 06155c05af89c957de20d2c53cee0e37171b9a09) * Add support for trusting dev certs on linux (#56582) * 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. * 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. * Clarify the mac-specific comments in GetDevelopmentCertificateFromStore (cherry picked from commit 27ae0821dded5d31b8f7a1cd4519a8ef3ccd6cc3) * Revert 9.0-specific changes * Restrict permissions to the dev cert directory (#56985) * Create directories with secure permissions If we're creating it, make it 700. If it already exists, warn if it's not 700. * Don't create a directory specified by the user (cherry picked from commit 1470e00eafadd592923504f77610e7fb486674bd) --- .../Shared/DevelopmentCertificate.cs | 2 +- .../Core/src/Internal/LoggerExtensions.cs | 3 + .../Kestrel/Core/src/KestrelServerOptions.cs | 25 +- .../Core/src/TlsConfigurationLoader.cs | 16 +- .../CertificateManager.cs | 276 +++++- .../EnsureCertificateResult.cs | 1 + .../MacOSCertificateManager.cs | 55 +- .../UnixCertificateManager.cs | 884 +++++++++++++++++- .../WindowsCertificateManager.cs | 75 +- .../test/CertificateManagerTests.cs | 73 +- src/Tools/dotnet-dev-certs/src/Program.cs | 56 +- 11 files changed, 1352 insertions(+), 114 deletions(-) diff --git a/src/ProjectTemplates/Shared/DevelopmentCertificate.cs b/src/ProjectTemplates/Shared/DevelopmentCertificate.cs index e9c8ea6fa169..080f35c3b3f8 100644 --- a/src/ProjectTemplates/Shared/DevelopmentCertificate.cs +++ b/src/ProjectTemplates/Shared/DevelopmentCertificate.cs @@ -35,7 +35,7 @@ private static string EnsureDevelopmentCertificates(string certificatePath, stri var manager = CertificateManager.Instance; var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1)); var certificateThumbprint = certificate.Thumbprint; - CertificateManager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx); + manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx); return certificateThumbprint; } diff --git a/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs b/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs index baa0c085d0a1..f78985136678 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 c2670be70067..95766ed3a8c7 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -385,13 +385,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); @@ -399,9 +402,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/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs b/src/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs index 087b06639483..2f19ea9acd26 100644 --- a/src/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs +++ b/src/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs @@ -176,20 +176,8 @@ public ListenOptions UseHttpsWithSni( private static bool IsDevelopmentCertificate(X509Certificate2 certificate) { - if (!string.Equals(certificate.Subject, "CN=localhost", StringComparison.Ordinal)) - { - return false; - } - - foreach (var ext in certificate.Extensions) - { - if (string.Equals(ext.Oid?.Value, CertificateManager.AspNetHttpsOid, StringComparison.Ordinal)) - { - return true; - } - } - - return false; + return string.Equals(certificate.Subject, CertificateManager.LocalhostHttpsDistinguishedName, StringComparison.Ordinal) && + CertificateManager.IsHttpsDevelopmentCertificate(certificate); } private static bool TryGetCertificatePath(string applicationName, [NotNullWhen(true)] out string? path) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index e5fba1e4108a..96e5630c43f1 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -26,7 +26,7 @@ internal abstract class CertificateManager private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; private const string LocalhostHttpsDnsName = "localhost"; - private const string LocalhostHttpsDistinguishedName = "CN=" + LocalhostHttpsDnsName; + internal const string LocalhostHttpsDistinguishedName = "CN=" + LocalhostHttpsDnsName; public const int RSAMinimumKeySizeInBits = 2048; @@ -62,9 +62,21 @@ internal CertificateManager(string subject, int version) AspNetHttpsCertificateVersion = version; } - public static bool IsHttpsDevelopmentCertificate(X509Certificate2 certificate) => - certificate.Extensions.OfType() - .Any(e => string.Equals(AspNetHttpsOid, e.Oid?.Value, StringComparison.Ordinal)); + /// + /// This only checks if the certificate has the OID for ASP.NET Core HTTPS development certificates - + /// it doesn't check the subject, validity, key usages, etc. + /// + public static bool IsHttpsDevelopmentCertificate(X509Certificate2 certificate) + { + foreach (var extension in certificate.Extensions.OfType()) + { + if (string.Equals(AspNetHttpsOid, extension.Oid?.Value, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } public IList ListCertificates( StoreName storeName, @@ -182,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); @@ -208,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 @@ -311,6 +323,14 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( { try { + // If the user specified a non-existent directory, we don't want to be responsible + // for setting the permissions appropriately, so we'll bail. + var exportDir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(exportDir) && !Directory.Exists(exportDir)) + { + throw new InvalidOperationException($"The directory '{exportDir}' does not exist. Choose permissions carefully when creating it."); + } + ExportCertificate(certificate, path, includePrivateKey, password, keyExportFormat); } catch (Exception e) @@ -333,7 +353,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) { @@ -398,7 +431,10 @@ internal ImportCertificateResult ImportCertificate(string certificatePath, strin return ImportCertificateResult.InvalidCertificate; } - if (!IsHttpsDevelopmentCertificate(certificate)) + // Note that we're checking Subject, rather than LocalhostHttpsDistinguishedName, + // because the tests use a different subject. + if (!string.Equals(certificate.Subject, Subject, StringComparison.Ordinal) || // Kestrel requires this + !IsHttpsDevelopmentCertificate(certificate)) { if (Log.IsEnabled()) { @@ -443,11 +479,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); @@ -455,7 +492,13 @@ public void CleanupHttpsCertificates() protected abstract IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation); - internal static void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format) + protected abstract void CreateDirectoryWithPermissions(string directoryPath); + + /// + /// Will create directories to make it possible to write to . + /// If you don't want that, check for existence before calling this method. + /// + internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format) { if (Log.IsEnabled()) { @@ -471,7 +514,7 @@ internal static void ExportCertificate(X509Certificate2 certificate, string path if (!string.IsNullOrEmpty(targetDirectoryPath)) { Log.CreateExportCertificateDirectory(targetDirectoryPath); - Directory.CreateDirectory(targetDirectoryPath); + CreateDirectoryWithPermissions(targetDirectoryPath); } byte[] bytes; @@ -665,7 +708,7 @@ internal X509Certificate2 SaveCertificate(X509Certificate2 certificate) return certificate; } - internal void TrustCertificate(X509Certificate2 certificate) + internal TrustLevel TrustCertificate(X509Certificate2 certificate) { try { @@ -673,8 +716,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()) { @@ -720,7 +764,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); @@ -767,7 +811,7 @@ internal static void DisposeCertificates(IEnumerable disposabl } } - protected virtual void RemoveCertificateFromUserStore(X509Certificate2 certificate) + protected void RemoveCertificateFromUserStore(X509Certificate2 certificate) { try { @@ -812,6 +856,53 @@ internal static string ToCertificateDescription(IEnumerable ce internal static string GetDescription(X509Certificate2 c) => $"{c.Thumbprint} - {c.Subject} - Valid from {c.NotBefore:u} to {c.NotAfter:u} - IsHttpsDevelopmentCertificate: {IsHttpsDevelopmentCertificate(c).ToString().ToLowerInvariant()} - IsExportable: {Instance.IsExportable(c).ToString().ToLowerInvariant()}"; + /// + /// is not adequate for security purposes. + /// + internal static bool AreCertificatesEqual(X509Certificate2 cert1, X509Certificate2 cert2) + { + return cert1.RawDataMemory.Span.SequenceEqual(cert2.RawDataMemory.Span); + } + + /// + /// Given a certificate, usually from the store, try to find the + /// corresponding certificate in (usually the store)."/> + /// + /// An open . + /// A certificate to search for. + /// The certificate, if any, corresponding to in . + /// True if a corresponding certificate was found. + /// has richer filtering and a lot of debugging output that's unhelpful here. + internal static bool TryFindCertificateInStore(X509Store store, X509Certificate2 certificate, [NotNullWhen(true)] out X509Certificate2? foundCertificate) + { + foundCertificate = null; + + // We specifically don't search by thumbprint to avoid being flagged for using a SHA-1 hash. + var certificatesWithSubjectName = store.Certificates.Find(X509FindType.FindBySerialNumber, certificate.SerialNumber, validOnly: false); + if (certificatesWithSubjectName.Count == 0) + { + return false; + } + + var certificatesToDispose = new List(); + foreach (var candidate in certificatesWithSubjectName.OfType()) + { + if (foundCertificate is null && AreCertificatesEqual(candidate, certificate)) + { + foundCertificate = candidate; + } + else + { + certificatesToDispose.Add(candidate); + } + } + DisposeCertificates(certificatesToDispose); + 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 { @@ -862,7 +953,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.")] @@ -1017,6 +1108,145 @@ 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); + + [Event(112, Level = EventLevel.Warning, Message = "Directory '{0}' may be readable by other users.")] + internal void DirectoryPermissionsNotSecure(string directoryPath) => WriteEvent(112, directoryPath); } internal sealed class UserCancelledTrustException : Exception @@ -1042,4 +1272,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 30237e445f61..a38e22762190 100644 --- a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs @@ -11,8 +11,15 @@ namespace Microsoft.AspNetCore.Certificates.Generation; +/// +/// Normally, we avoid the use of because it's a SHA-1 hash and, therefore, +/// not adequate for security applications. However, the MacOS security tool uses SHA-1 hashes for certificate +/// identification, so we're stuck. +/// internal sealed class MacOSCertificateManager : CertificateManager { + private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + // User keychain. Guard with quotes when using in command lines since users may have set // their user profile (HOME) directory to a non-standard path that includes whitespace. private static readonly string MacOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db"; @@ -64,12 +71,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() { @@ -80,17 +82,20 @@ 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(); try { + // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx); if (Log.IsEnabled()) { @@ -106,6 +111,7 @@ protected override void TrustCertificateCore(X509Certificate2 publicCertificate) } } Log.MacOSTrustCommandEnd(); + return TrustLevel.Full; } finally { @@ -120,7 +126,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) : @@ -131,9 +137,7 @@ internal override void CorrectCertificateState(X509Certificate2 candidate) { try { - // Ensure that the directory exists before writing to the file. - Directory.CreateDirectory(MacOSUserHttpsCertificateLocation); - + // This path is in a well-known folder, so we trust the permissions. var certificatePath = GetCertificateFilePath(candidate); ExportCertificate(candidate, certificatePath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx); } @@ -144,11 +148,12 @@ 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 { + // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); using var checkTrustProcess = Process.Start(new ProcessStartInfo( @@ -161,7 +166,7 @@ public override bool IsTrusted(X509Certificate2 certificate) RedirectStandardError = true, }); checkTrustProcess!.WaitForExit(); - return checkTrustProcess.ExitCode == 0; + return checkTrustProcess.ExitCode == 0 ? TrustLevel.Full : TrustLevel.None; } finally { @@ -313,7 +318,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi } // Ensure that the directory exists before writing to the file. - Directory.CreateDirectory(MacOSUserHttpsCertificateLocation); + CreateDirectoryWithPermissions(MacOSUserHttpsCertificateLocation); File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes); } @@ -471,4 +476,22 @@ protected override void RemoveCertificateFromUserStoreCore(X509Certificate2 cert RemoveCertificateFromKeychain(MacOSUserKeychain, certificate); } } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { +#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows) + var dirInfo = new DirectoryInfo(directoryPath); + if (dirInfo.Exists) + { + if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0) + { + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + } + } + else + { + Directory.CreateDirectory(directoryPath, DirectoryPermissions); + } +#pragma warning restore CA1416 // Validate platform compatibility + } } diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index d32235568924..209da55e80c4 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -1,14 +1,44 @@ // 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 { + private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + /// 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,7 +48,106 @@ internal UnixCertificateManager(string subject, int version) { } - public override bool IsTrusted(X509Certificate2 certificate) => false; + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) + { + 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; + if (chain.Build(certificate)) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByDotnet(); + } + + // Will become the name of the file on disk and the nickname in the NSS DBs + var certificateNickname = 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, certificateNickname + ".pem"); + if (File.Exists(certPath)) + { + var candidate = new X509Certificate2(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(certificateNickname, nssDb)) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByNss(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + } + } + } + } + + // Success & Failure => Partial; Success => Full; Failure => None + return sawTrustSuccess + ? sawTrustFailure + ? TrustLevel.Partial + : TrustLevel.Full + : TrustLevel.None; + } protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) { @@ -37,29 +166,768 @@ 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 = new X509Certificate2(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 = new X509Certificate2(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. We control `certPath`, so the permissions should be fine. + 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); } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { +#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows) + var dirInfo = new DirectoryInfo(directoryPath); + if (dirInfo.Exists) + { + if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0) + { + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + } + } + else + { + Directory.CreateDirectory(directoryPath, DirectoryPermissions); + } +#pragma warning restore CA1416 // Validate platform compatibility + } + + 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 69f57066438b..3b7014f7c78c 100644 --- a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs +++ b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs @@ -5,8 +5,10 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.Versioning; +using System.Security.AccessControl; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; namespace Microsoft.AspNetCore.Certificates.Generation; @@ -39,7 +41,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,28 +71,25 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi return certificate; } - protected override void TrustCertificateCore(X509Certificate2 certificate) + protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) { - using var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert)); - - publicCertificate.FriendlyName = certificate.FriendlyName; - using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); - store.Open(OpenFlags.ReadWrite); - var existing = store.Certificates.Find(X509FindType.FindByThumbprint, publicCertificate.Thumbprint, validOnly: false); - if (existing.Count > 0) + + if (TryFindCertificateInStore(store, certificate, out _)) { Log.WindowsCertificateAlreadyTrusted(); - DisposeCertificates(existing.OfType()); - return; + return TrustLevel.Full; } try { Log.WindowsAddCertificateToRootStore(); + + using var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert)); + publicCertificate.FriendlyName = certificate.FriendlyName; store.Add(publicCertificate); - store.Close(); + return TrustLevel.Full; } catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode) { @@ -102,14 +101,11 @@ protected override void TrustCertificateCore(X509Certificate2 certificate) protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) { Log.WindowsRemoveCertificateFromRootStoreStart(); - using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); store.Open(OpenFlags.ReadWrite); - var matching = store.Certificates - .OfType() - .SingleOrDefault(c => c.SerialNumber == certificate.SerialNumber); - if (matching != null) + if (TryFindCertificateInStore(store, certificate, out var matching)) { store.Remove(matching); } @@ -118,18 +114,55 @@ protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certi Log.WindowsRemoveCertificateFromRootStoreNotFound(); } - store.Close(); Log.WindowsRemoveCertificateFromRootStoreEnd(); } - public override bool IsTrusted(X509Certificate2 certificate) + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) { - return ListCertificates(StoreName.Root, StoreLocation.CurrentUser, isValid: true, requireExportable: false) - .Any(c => c.Thumbprint == certificate.Thumbprint); + 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) { return ListCertificates(storeName, storeLocation, isValid: false); } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { + var dirInfo = new DirectoryInfo(directoryPath); + + if (!dirInfo.Exists) + { + // We trust the default permissions on Windows enough not to apply custom ACLs. + // We'll warn below if things seem really off. + dirInfo.Create(); + } + + var currentUser = WindowsIdentity.GetCurrent(); + var currentUserSid = currentUser.User; + var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, domainSid: null); + var adminGroupSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, domainSid: null); + + var dirSecurity = dirInfo.GetAccessControl(); + var accessRules = dirSecurity.GetAccessRules(true, true, typeof(SecurityIdentifier)); + + foreach (FileSystemAccessRule rule in accessRules) + { + var idRef = rule.IdentityReference; + if (rule.AccessControlType == AccessControlType.Allow && + !idRef.Equals(currentUserSid) && + !idRef.Equals(systemSid) && + !idRef.Equals(adminGroupSid)) + { + // This is just a heuristic - determining whether the cumulative effect of the rules + // is to allow access to anyone other than the current user, system, or administrators + // is very complicated. We're not going to do anything but log, so an approximation + // is fine. + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + break; + } + } + } } diff --git a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs index 568bd4d4753c..4edd3897b652 100644 --- a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs +++ b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs @@ -116,7 +116,7 @@ private void ListCertificates() var certificates = store.Certificates; foreach (var certificate in certificates) { - Output.WriteLine($"Certificate: '{Convert.ToBase64String(certificate.Export(X509ContentType.Cert))}'."); + Output.WriteLine($"Certificate: {certificate.Subject} '{Convert.ToBase64String(certificate.Export(X509ContentType.Cert))}'."); certificate.Dispose(); } @@ -225,7 +225,7 @@ public void EnsureCreateHttpsCertificate_CanExportTheCertInPemFormat_WithoutKey( public void EnsureCreateHttpsCertificate_CanImport_ExportedPfx() { // Arrange - const string CertificateName = nameof(EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates) + ".pfx"; + const string CertificateName = nameof(EnsureCreateHttpsCertificate_CanImport_ExportedPfx) + ".pfx"; var certificatePassword = Guid.NewGuid().ToString(); _fixture.CleanupCertificates(); @@ -258,7 +258,7 @@ public void EnsureCreateHttpsCertificate_CanImport_ExportedPfx() public void EnsureCreateHttpsCertificate_CanImport_ExportedPfx_FailsIfThereAreCertificatesPresent() { // Arrange - const string CertificateName = nameof(EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates) + ".pfx"; + const string CertificateName = nameof(EnsureCreateHttpsCertificate_CanImport_ExportedPfx_FailsIfThereAreCertificatesPresent) + ".pfx"; var certificatePassword = Guid.NewGuid().ToString(); _fixture.CleanupCertificates(); @@ -280,6 +280,47 @@ public void EnsureCreateHttpsCertificate_CanImport_ExportedPfx_FailsIfThereAreCe Assert.Equal(ImportCertificateResult.ExistingCertificatesPresent, result); } + [ConditionalFact] + [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "All.OSX")] + public void EnsureCreateHttpsCertificate_CannotImportIfTheSubjectNameIsWrong() + { + // Arrange + const string CertificateName = nameof(EnsureCreateHttpsCertificate_CannotImportIfTheSubjectNameIsWrong) + ".pfx"; + var certificatePassword = Guid.NewGuid().ToString(); + + _fixture.CleanupCertificates(); + + var now = DateTimeOffset.UtcNow; + now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); + + var httpsCertificate = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); + + _manager.CleanupHttpsCertificates(); + + using var privateKey = httpsCertificate.GetRSAPrivateKey(); + var csr = new CertificateRequest(httpsCertificate.Subject + "Not", privateKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + foreach (var extension in httpsCertificate.Extensions) + { + csr.CertificateExtensions.Add(extension); + } + var wrongSubjectCertificate = csr.CreateSelfSigned(httpsCertificate.NotBefore, httpsCertificate.NotAfter); + + Assert.True(CertificateManager.IsHttpsDevelopmentCertificate(wrongSubjectCertificate)); + Assert.NotEqual(_manager.Subject, wrongSubjectCertificate.Subject); + + File.WriteAllBytes(CertificateName, wrongSubjectCertificate.Export(X509ContentType.Pfx, certificatePassword)); + + // Act + var result = _manager.ImportCertificate(CertificateName, certificatePassword); + + // Assert + Assert.Equal(ImportCertificateResult.NoDevelopmentHttpsCertificate, result); + Assert.Empty(_manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false)); + } + [ConditionalFact] [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "All.OSX")] public void EnsureCreateHttpsCertificate_CanExportTheCertInPemFormat_WithoutPassword() @@ -311,6 +352,30 @@ public void EnsureCreateHttpsCertificate_CanExportTheCertInPemFormat_WithoutPass Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); } + [ConditionalFact] + [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "All.OSX")] + public void EnsureCreateHttpsCertificate_CannotExportToNonExistentDirectory() + { + // Arrange + const string CertificateName = nameof(EnsureCreateHttpsCertificate_CannotExportToNonExistentDirectory) + ".pem"; + + _fixture.CleanupCertificates(); + + var now = DateTimeOffset.UtcNow; + now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); + + // Act + // Export the certificate (same method, but this time with an output path) + var result = _manager + .EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), Path.Combine("NoSuchDirectory", CertificateName)); + + // Assert + Assert.Equal(EnsureCertificateResult.ErrorExportingTheCertificate, result); + } + [Fact] public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateIfVersionIsIncorrect() { @@ -468,7 +533,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.");