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.");