[HTTPS] Update certificate strategy for Mac OS (#20022)

* Fixes and improvements for dotnet-dev-certs:
* Revamps the HTTPS developer certificate tool implementation.
  * It accumulated a lot of cruft during the past few years and that has made making changes harder.
* Separated the CertificateManager implementation into different classes per platform.
  * This centralizes the decision point of choosing a platform in a single place.
  * Makes clear what the flow is for a given platform.
  * Isolates changes needed for a given platform in the future.
* Moved CertificateManager to a singleton
  * No more statics!
* Updates logging to use EventSource
  * We didn't have a good way of performing logging as the code is shared and must run in multiple contexts and the set of dependencies need to be kept to a minimum.
  * Adding ETW allow us to log/monitor the the tool execution and capture the logs with `dotnet trace` without having to invent our own logging.
  * We can decide to write an EventListener in `dotnet-dev-certs` to write the results to the console output.
* Updates the way we handle the dev-cert in Mac OS to use the security tool to import the certificate into the store instead of using the certificate store.
This commit is contained in:
Javier Calvarro Nelson 2020-04-16 22:52:10 +02:00 committed by GitHub
parent 00623928f6
commit 8e1e81ae78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1210 additions and 974 deletions

View File

@ -89,8 +89,8 @@ namespace Templates.Test.Helpers
internal void EnsureDevelopmentCertificates()
{
var now = DateTimeOffset.Now;
var manager = new CertificateManager();
var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), "CN=localhost");
var manager = CertificateManager.Instance;
var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
manager.ExportCertificate(certificate, path: _certificatePath, includePrivateKey: true, _certificatePassword);
}

View File

@ -34,6 +34,18 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
new EventId(3, "FailedToLoadDevelopmentCertificate"),
"Failed to load the development https certificate at '{certificatePath}'.");
private static readonly Action<ILogger, Exception> _badDeveloperCertificateState =
LoggerMessage.Define(
LogLevel.Error,
new EventId(4, "BadDeveloperCertificateState"),
CoreStrings.BadDeveloperCertificateState);
private static readonly Action<ILogger, string, Exception> _developerCertificateFirstRun =
LoggerMessage.Define<string>(
LogLevel.Warning,
new EventId(5, "DeveloperCertificateFirstRun"),
"{Message}");
public static void LocatedDevelopmentCertificate(this ILogger logger, X509Certificate2 certificate) => _locatedDevelopmentCertificate(logger, certificate.Subject, certificate.Thumbprint, null);
public static void UnableToLocateDevelopmentCertificate(this ILogger logger) => _unableToLocateDevelopmentCertificate(logger, null);
@ -41,5 +53,9 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
public static void FailedToLocateDevelopmentCertificateFile(this ILogger logger, string certificatePath) => _failedToLocateDevelopmentCertificateFile(logger, certificatePath, null);
public static void FailedToLoadDevelopmentCertificate(this ILogger logger, string certificatePath) => _failedToLoadDevelopmentCertificate(logger, certificatePath, null);
public static void BadDeveloperCertificateState(this ILogger logger) => _badDeveloperCertificateState(logger, null);
public static void DeveloperCertificateFirstRun(this ILogger logger, string message) => _developerCertificateFirstRun(logger, message, null);
}
}

View File

@ -162,11 +162,29 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core
var logger = ApplicationServices.GetRequiredService<ILogger<KestrelServer>>();
try
{
DefaultCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true)
DefaultCertificate = CertificateManager.Instance.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true)
.FirstOrDefault();
if (DefaultCertificate != null)
{
var status = CertificateManager.Instance.CheckCertificateState(DefaultCertificate, interactive: false);
if (!status.Result)
{
// 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.
logger.DeveloperCertificateFirstRun(status.Message);
// Now that we've displayed a warning in the logs so that the user gets a notification that a prompt might appear, try
// and access the certificate key, which might trigger a prompt.
status = CertificateManager.Instance.CheckCertificateState(DefaultCertificate, interactive: true);
if (!status.Result)
{
logger.BadDeveloperCertificateState();
}
}
logger.LocatedDevelopmentCertificate(DefaultCertificate);
}
else

View File

@ -220,16 +220,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https.Internal
}
catch (AuthenticationException ex)
{
if (_serverCertificate == null ||
!CertificateManager.IsHttpsDevelopmentCertificate(_serverCertificate) ||
CertificateManager.CheckDeveloperCertificateKey(_serverCertificate))
{
_logger.LogDebug(1, ex, CoreStrings.AuthenticationFailed);
}
else
{
_logger.LogError(3, ex, CoreStrings.BadDeveloperCertificateState);
}
_logger.LogDebug(1, ex, CoreStrings.AuthenticationFailed);
await sslStream.DisposeAsync();
return;

View File

@ -385,35 +385,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests
Assert.Equal(LogLevel.Debug, loggerProvider.FilterLogger.LastLogLevel);
}
[Fact]
public async Task DevCertWithInvalidPrivateKeyProducesCustomWarning()
{
var loggerProvider = new HandshakeErrorLoggerProvider();
LoggerFactory.AddProvider(loggerProvider);
await using (var server = new TestServer(context => Task.CompletedTask,
new TestServiceContext(LoggerFactory),
listenOptions =>
{
listenOptions.UseHttps(TestResources.GetTestCertificate("aspnetdevcert.pfx", "testPassword"));
}))
{
using (var connection = server.CreateConnection())
using (var sslStream = new SslStream(connection.Stream, true, (sender, certificate, chain, errors) => true))
{
// SslProtocols.Tls is TLS 1.0 which isn't supported by Kestrel by default.
await Assert.ThrowsAnyAsync<Exception>(() =>
sslStream.AuthenticateAsClientAsync("127.0.0.1", clientCertificates: null,
enabledSslProtocols: SslProtocols.Tls,
checkCertificateRevocation: false));
}
}
await loggerProvider.FilterLogger.LogTcs.Task.DefaultTimeout();
Assert.Equal(3, loggerProvider.FilterLogger.LastEventId);
Assert.Equal(LogLevel.Error, loggerProvider.FilterLogger.LastLogLevel);
}
[Fact]
public async Task OnAuthenticate_SeesOtherSettings()
{

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
namespace Microsoft.AspNetCore.Certificates.Generation
{
internal class MacOSCertificateManager : CertificateManager
{
private const string CertificateSubjectRegex = "CN=(.*[^,]+).*";
private static readonly string MacOSUserKeyChain = Environment.GetEnvironmentVariable("HOME") + "/Library/Keychains/login.keychain-db";
private const string MacOSSystemKeyChain = "/Library/Keychains/System.keychain";
private const string MacOSFindCertificateCommandLine = "security";
private static readonly string MacOSFindCertificateCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p " + MacOSSystemKeyChain;
private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)";
private const string MacOSRemoveCertificateTrustCommandLine = "sudo";
private const string MacOSRemoveCertificateTrustCommandLineArgumentsFormat = "security remove-trusted-cert -d {0}";
private const string MacOSDeleteCertificateCommandLine = "sudo";
private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} {1}";
private const string MacOSTrustCertificateCommandLine = "sudo";
private static readonly string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " ";
private const string MacOSAddCertificateToKeyChainCommandLine = "security";
private static readonly string MacOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import {0} -k " + MacOSUserKeyChain + " -t cert -f pkcs12 -P {1} -A";
public const string InvalidCertificateState = "The ASP.NET Core developer certificate is in an invalid state. " +
"To fix this issue, run the following commands '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.";
private static readonly TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1);
public MacOSCertificateManager()
{
}
internal MacOSCertificateManager(string subject, int version)
: base(subject, version)
{
}
protected override void TrustCertificateCore(X509Certificate2 publicCertificate)
{
var tmpFile = Path.GetTempFileName();
try
{
ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null);
Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {MacOSTrustCertificateCommandLineArguments}{tmpFile}");
using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile))
{
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.MacOSTrustCommandError(process.ExitCode);
throw new InvalidOperationException("There was an error trusting the certificate.");
}
}
Log.MacOSTrustCommandEnd();
}
finally
{
try
{
if (File.Exists(tmpFile))
{
File.Delete(tmpFile);
}
}
catch
{
// We don't care if we can't delete the temp file.
}
}
}
internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive)
{
var sentinelPath = Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".dotnet", $"certificates.{candidate.GetCertHashString(HashAlgorithmName.SHA256)}.sentinel");
if (!interactive && !File.Exists(sentinelPath))
{
return new CheckCertificateStateResult(false, KeyNotAccessibleWithoutUserInteraction);
}
// Tries to use the certificate key to validate it can't access it
try
{
var rsa = candidate.GetRSAPrivateKey();
if (rsa == null)
{
return new CheckCertificateStateResult(false, InvalidCertificateState);
}
// Encrypting a random value is the ultimate test for a key validity.
// Windows and Mac OS both return HasPrivateKey = true if there is (or there has been) a private key associated
// with the certificate at some point.
var value = new byte[32];
RandomNumberGenerator.Fill(value);
rsa.Decrypt(rsa.Encrypt(value, RSAEncryptionPadding.Pkcs1), RSAEncryptionPadding.Pkcs1);
// If we were able to access the key, create a sentinel so that we don't have to show a prompt
// on every kestrel run.
if (Directory.Exists(Path.GetDirectoryName(sentinelPath)) && !File.Exists(sentinelPath))
{
File.WriteAllText(sentinelPath, "true");
}
// Being able to encrypt and decrypt a payload is the strongest guarantee that the key is valid.
return new CheckCertificateStateResult(true, null);
}
catch (Exception)
{
return new CheckCertificateStateResult(false, InvalidCertificateState);
}
}
internal override void CorrectCertificateState(X509Certificate2 candidate)
{
var status = CheckCertificateState(candidate, true);
if (!status.Result)
{
throw new InvalidOperationException(InvalidCertificateState);
}
}
public override bool IsTrusted(X509Certificate2 certificate)
{
var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, MaxRegexTimeout);
if (!subjectMatch.Success)
{
throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'.");
}
var subject = subjectMatch.Groups[1].Value;
using var checkTrustProcess = Process.Start(new ProcessStartInfo(
MacOSFindCertificateCommandLine,
string.Format(MacOSFindCertificateCommandLineArgumentsFormat, subject))
{
RedirectStandardOutput = true
});
var output = checkTrustProcess.StandardOutput.ReadToEnd();
checkTrustProcess.WaitForExit();
var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, MaxRegexTimeout);
var hashes = matches.OfType<Match>().Select(m => m.Groups[1].Value).ToList();
return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal));
}
protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
{
if (IsTrusted(certificate)) // On OSX this check just ensures its on the system keychain
{
// A trusted certificate in OSX is installed into the system keychain and
// as a "trust rule" applied to it.
// To remove the certificate we first need to remove the "trust rule" and then
// remove the certificate from the keychain.
// We don't care if we fail to remove the trust rule if
// for some reason the certificate became untrusted.
// Trying to remove the certificate from the keychain will fail if the certificate is
// trusted.
try
{
RemoveCertificateTrustRule(certificate);
}
catch
{
}
RemoveCertificateFromKeyChain(MacOSSystemKeyChain, certificate);
}
else
{
Log.MacOSCertificateUntrusted(GetDescription(certificate));
}
}
private static void RemoveCertificateTrustRule(X509Certificate2 certificate)
{
Log.MacOSRemoveCertificateTrustRuleStart(GetDescription(certificate));
var certificatePath = Path.GetTempFileName();
try
{
var certBytes = certificate.Export(X509ContentType.Cert);
File.WriteAllBytes(certificatePath, certBytes);
var processInfo = new ProcessStartInfo(
MacOSRemoveCertificateTrustCommandLine,
string.Format(
MacOSRemoveCertificateTrustCommandLineArgumentsFormat,
certificatePath
));
using var process = Process.Start(processInfo);
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.MacOSRemoveCertificateTrustRuleError(process.ExitCode);
}
Log.MacOSRemoveCertificateTrustRuleEnd();
}
finally
{
try
{
if (File.Exists(certificatePath))
{
File.Delete(certificatePath);
}
}
catch
{
// We don't care about failing to do clean-up on a temp file.
}
}
}
private static void RemoveCertificateFromKeyChain(string keyChain, X509Certificate2 certificate)
{
var processInfo = new ProcessStartInfo(
MacOSDeleteCertificateCommandLine,
string.Format(
MacOSDeleteCertificateCommandLineArgumentsFormat,
certificate.Thumbprint.ToUpperInvariant(),
keyChain
))
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
Log.MacOSRemoveCertificateFromKeyChainStart(keyChain, GetDescription(certificate));
using (var process = Process.Start(processInfo))
{
var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.MacOSRemoveCertificateFromKeyChainError(process.ExitCode);
throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'.
{output}");
}
}
Log.MacOSRemoveCertificateFromKeyChainEnd();
}
// We don't have a good way of checking on the underlying implementation if ti is exportable, so just return true.
protected override bool IsExportable(X509Certificate2 c) => true;
protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate)
{
// security import https.pfx -k $loginKeyChain -t cert -f pkcs12 -P password -A;
var passwordBytes = new byte[48];
RandomNumberGenerator.Fill(passwordBytes.AsSpan()[0..35]);
var password = Convert.ToBase64String(passwordBytes, 0, 36);
var certBytes = certificate.Export(X509ContentType.Pfx, password);
var certificatePath = Path.GetTempFileName();
File.WriteAllBytes(certificatePath, certBytes);
var processInfo = new ProcessStartInfo(
MacOSAddCertificateToKeyChainCommandLine,
string.Format(
MacOSAddCertificateToKeyChainCommandLineArgumentsFormat,
certificatePath,
password
))
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeyChain, GetDescription(certificate));
using (var process = Process.Start(processInfo))
{
var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.MacOSAddCertificateToKeyChainError(process.ExitCode);
throw new InvalidOperationException($@"There was an error importing the certificate into the user key chain '{certificate.Thumbprint}'.
{output}");
}
}
Log.MacOSAddCertificateToKeyChainEnd();
return certificate;
}
protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
{
return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false);
}
}
}

View File

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
namespace Microsoft.AspNetCore.Certificates.Generation
{
internal class UnixCertificateManager : CertificateManager
{
public UnixCertificateManager()
{
}
internal UnixCertificateManager(string subject, int version)
: base(subject, version)
{
}
public override bool IsTrusted(X509Certificate2 certificate) => false;
protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate)
{
var export = certificate.Export(X509ContentType.Pkcs12, "");
certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);
Array.Clear(export, 0, export.Length);
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadWrite);
store.Add(certificate);
store.Close();
};
return certificate;
}
internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive)
{
// Return true as we don't perform any check.
return new CheckCertificateStateResult(true, null);
}
internal override void CorrectCertificateState(X509Certificate2 candidate)
{
// Do nothing since we don't have anything to check here.
}
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 void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
{
// No-op here as is benign
}
protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
{
return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false);
}
}
}

View File

@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace Microsoft.AspNetCore.Certificates.Generation
{
internal class WindowsCertificateManager : CertificateManager
{
private const int UserCancelledErrorCode = 1223;
public WindowsCertificateManager()
{
}
// For testing purposes only
internal WindowsCertificateManager(string subject, int version)
: base(subject, version)
{
}
protected override bool IsExportable(X509Certificate2 c)
{
#if XPLAT
// For the first run experience we don't need to know if the certificate can be exported.
return true;
#else
return (c.GetRSAPrivateKey() is RSACryptoServiceProvider rsaPrivateKey &&
rsaPrivateKey.CspKeyContainerInfo.Exportable) ||
(c.GetRSAPrivateKey() is RSACng cngPrivateKey &&
cngPrivateKey.Key.ExportPolicy == CngExportPolicies.AllowExport);
#endif
}
internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive)
{
// Return true as we don't perform any check.
return new CheckCertificateStateResult(true, null);
}
internal override void CorrectCertificateState(X509Certificate2 candidate)
{
// Do nothing since we don't have anything to check here.
}
protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate)
{
// On non OSX systems we need to export the certificate and import it so that the transient
// key that we generated gets persisted.
var export = certificate.Export(X509ContentType.Pkcs12, "");
certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);
Array.Clear(export, 0, export.Length);
certificate.FriendlyName = AspNetHttpsOidFriendlyName;
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadWrite);
store.Add(certificate);
store.Close();
};
return certificate;
}
protected override void TrustCertificateCore(X509Certificate2 certificate)
{
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)
{
Log.WindowsCertificateAlreadyTrusted();
DisposeCertificates(existing.OfType<X509Certificate2>());
return;
}
try
{
Log.WindowsAddCertificateToRootStore();
store.Add(publicCertificate);
store.Close();
}
catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode)
{
Log.WindowsCertificateTrustCanceled();
throw new UserCancelledTrustException();
}
}
protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
{
Log.WindowsRemoveCertificateFromRootStoreStart();
using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadWrite);
var matching = store.Certificates
.OfType<X509Certificate2>()
.SingleOrDefault(c => c.SerialNumber == certificate.SerialNumber);
if (matching != null)
{
store.Remove(matching);
}
else
{
Log.WindowsRemoveCertificateFromRootStoreNotFound();
}
store.Close();
Log.WindowsRemoveCertificateFromRootStoreEnd();
}
public override bool IsTrusted(X509Certificate2 certificate)
{
return ListCertificates(StoreName.Root, StoreLocation.CurrentUser, isValid: true, requireExportable: false)
.Any(c => c.Thumbprint == certificate.Thumbprint);
}
protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
{
return ListCertificates(storeName, storeLocation, isValid: false);
}
}
}

View File

@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.XPlat
{
public static void GenerateAspNetHttpsCertificate()
{
var manager = new CertificateManager();
var manager = CertificateManager.Instance;
var now = DateTimeOffset.Now;
manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), isInteractive: false);
}

View File

@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
{
public class CertificateManagerTests : IClassFixture<CertFixture>
{
private CertFixture _fixture;
private readonly CertFixture _fixture;
private CertificateManager _manager => _fixture.Manager;
public CertificateManagerTests(ITestOutputHelper output, CertFixture fixture)
@ -39,20 +39,20 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
const string CertificateName = nameof(EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttpsCertificates) + ".cer";
// Act
DateTimeOffset now = DateTimeOffset.UtcNow;
// Act
var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, subject: TestCertificateSubject, isInteractive: false);
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, isInteractive: false);
// Assert
Assert.Equal(EnsureCertificateResult.Succeeded, result.ResultCode);
Assert.Equal(EnsureCertificateResult.Succeeded, result);
Assert.True(File.Exists(CertificateName));
var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName));
Assert.NotNull(exportedCertificate);
Assert.False(exportedCertificate.HasPrivateKey);
var httpsCertificates = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false);
var httpsCertificates = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false);
var httpsCertificate = Assert.Single(httpsCertificates, c => c.Subject == TestCertificateSubject);
Assert.True(httpsCertificate.HasPrivateKey);
Assert.Equal(TestCertificateSubject, httpsCertificate.Subject);
@ -94,7 +94,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
httpsCertificate.Extensions.OfType<X509Extension>(),
e => e.Critical == false &&
e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" &&
e.RawData[0] == CertificateManager.AspNetHttpsCertificateVersion);
e.RawData[0] == _manager.AspNetHttpsCertificateVersion);
Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString());
@ -102,12 +102,12 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
catch (Exception e)
{
Output.WriteLine(e.Message);
ListCertificates(Output);
ListCertificates();
throw;
}
}
private void ListCertificates(ITestOutputHelper output)
private void ListCertificates()
{
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
@ -133,17 +133,18 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
_fixture.CleanupCertificates();
DateTimeOffset now = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
var httpsCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject);
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);
// Act
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, subject: TestCertificateSubject, isInteractive: false);
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, isInteractive: false);
// Assert
Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result.ResultCode);
Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result);
Assert.True(File.Exists(CertificateName));
var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName), certificatePassword);
@ -159,13 +160,15 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
{
_fixture.CleanupCertificates();
DateTimeOffset now = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false);
Output.WriteLine(creation.ToString());
ListCertificates();
CertificateManager.AspNetHttpsCertificateVersion = 2;
_manager.AspNetHttpsCertificateVersion = 2;
var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
Assert.Empty(httpsCertificateList);
}
@ -174,14 +177,16 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
{
_fixture.CleanupCertificates();
DateTimeOffset now = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
CertificateManager.AspNetHttpsCertificateVersion = 0;
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
_manager.AspNetHttpsCertificateVersion = 0;
var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false);
Output.WriteLine(creation.ToString());
ListCertificates();
CertificateManager.AspNetHttpsCertificateVersion = 1;
_manager.AspNetHttpsCertificateVersion = 1;
var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
Assert.Empty(httpsCertificateList);
}
@ -191,12 +196,14 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
{
_fixture.CleanupCertificates();
DateTimeOffset now = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
CertificateManager.AspNetHttpsCertificateVersion = 0;
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
_manager.AspNetHttpsCertificateVersion = 0;
var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false);
Output.WriteLine(creation.ToString());
ListCertificates();
var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
Assert.NotEmpty(httpsCertificateList);
}
@ -206,45 +213,17 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
{
_fixture.CleanupCertificates();
DateTimeOffset now = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
CertificateManager.AspNetHttpsCertificateVersion = 2;
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
_manager.AspNetHttpsCertificateVersion = 2;
var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false);
Output.WriteLine(creation.ToString());
ListCertificates();
CertificateManager.AspNetHttpsCertificateVersion = 1;
var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
_manager.AspNetHttpsCertificateVersion = 1;
var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
Assert.NotEmpty(httpsCertificateList);
}
[Fact(Skip = "Requires user interaction")]
public void EnsureAspNetCoreHttpsDevelopmentCertificate_ReturnsCorrectResult_WhenUserCancelsTrustStepOnWindows()
{
_fixture.CleanupCertificates();
DateTimeOffset now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
var trustFailed = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject, isInteractive: false);
Assert.Equal(EnsureCertificateResult.UserCancelledTrustStep, trustFailed.ResultCode);
}
[Fact(Skip = "Requires user interaction")]
public void EnsureAspNetCoreHttpsDevelopmentCertificate_CanRemoveCertificates()
{
_fixture.CleanupCertificates();
DateTimeOffset now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject, isInteractive: false);
_manager.CleanupHttpsCertificates(TestCertificateSubject);
Assert.Empty(CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false).Where(c => c.Subject == TestCertificateSubject));
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Assert.Empty(CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, isValid: false).Where(c => c.Subject == TestCertificateSubject));
}
}
}
public class CertFixture : IDisposable
@ -253,24 +232,25 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
public CertFixture()
{
Manager = new CertificateManager();
Manager = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
new WindowsCertificateManager(TestCertificateSubject, 1) :
RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ?
new MacOSCertificateManager(TestCertificateSubject, 1) as CertificateManager :
new UnixCertificateManager(TestCertificateSubject, 1);
CleanupCertificates();
}
internal CertificateManager Manager { get; set; }
public void Dispose()
{
CleanupCertificates();
}
public void Dispose() => CleanupCertificates();
internal void CleanupCertificates()
{
Manager.RemoveAllCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, TestCertificateSubject);
Manager.RemoveAllCertificates(StoreName.My, StoreLocation.CurrentUser);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Manager.RemoveAllCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, TestCertificateSubject);
Manager.RemoveAllCertificates(StoreName.Root, StoreLocation.CurrentUser);
}
}
}

View File

@ -35,6 +35,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GetDocumentInsider", "GetDo
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.ApiDescription.Client.Tests", "Extensions.ApiDescription.Client\test\Microsoft.Extensions.ApiDescription.Client.Tests.csproj", "{2C62584B-EC31-40C8-819B-E46334645AE5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests", "FirstRunCertGenerator\test\Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests.csproj", "{88712247-88C1-442B-874D-69D4B302EEBF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat", "FirstRunCertGenerator\src\Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj", "{28E3181D-FAAA-483C-A924-3AF8D3F274A3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -93,6 +97,14 @@ Global
{2C62584B-EC31-40C8-819B-E46334645AE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C62584B-EC31-40C8-819B-E46334645AE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C62584B-EC31-40C8-819B-E46334645AE5}.Release|Any CPU.Build.0 = Release|Any CPU
{88712247-88C1-442B-874D-69D4B302EEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{88712247-88C1-442B-874D-69D4B302EEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{88712247-88C1-442B-874D-69D4B302EEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{88712247-88C1-442B-874D-69D4B302EEBF}.Release|Any CPU.Build.0 = Release|Any CPU
{28E3181D-FAAA-483C-A924-3AF8D3F274A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{28E3181D-FAAA-483C-A924-3AF8D3F274A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{28E3181D-FAAA-483C-A924-3AF8D3F274A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{28E3181D-FAAA-483C-A924-3AF8D3F274A3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -112,6 +124,8 @@ Global
{233119FC-E4C1-421C-89AE-1A445C5A947F} = {003EA860-5DFC-40AE-87C0-9D21BB2C68D7}
{EB63AECB-B898-475D-90F7-FE61F9C1CCC6} = {003EA860-5DFC-40AE-87C0-9D21BB2C68D7}
{2C62584B-EC31-40C8-819B-E46334645AE5} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44}
{88712247-88C1-442B-874D-69D4B302EEBF} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44}
{28E3181D-FAAA-483C-A924-3AF8D3F274A3} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {EC668D8E-97B9-4758-9E5C-2E5DD6B9137B}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
@ -24,12 +25,22 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
private const int ErrorNoValidCertificateFound = 6;
private const int ErrorCertificateNotTrusted = 7;
private const int ErrorCleaningUpCertificates = 8;
private const int ErrorMacOsCertificateKeyCouldNotBeAccessible = 9;
private const int InvalidCertificateState = 9;
public static readonly TimeSpan HttpsCertificateValidity = TimeSpan.FromDays(365);
public static int Main(string[] args)
{
if (args.Contains("--debug"))
{
// This is so that we can attach `dotnet trace` for debug purposes.
Console.WriteLine("Press any key to continue...");
_ = Console.ReadKey();
var newArgs = new List<string>(args);
newArgs.Remove("--debug");
args = newArgs.ToArray();
}
try
{
var app = new CommandLineApplication
@ -120,7 +131,7 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
private static int CleanHttpsCertificates(IReporter reporter)
{
var manager = new CertificateManager();
var manager = CertificateManager.Instance;
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
@ -138,7 +149,7 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
reporter.Output("HTTPS development certificates successfully removed from the machine.");
return Success;
}
catch(Exception e)
catch (Exception e)
{
reporter.Error("There was an error trying to clean HTTPS development certificates on this machine.");
reporter.Error(e.Message);
@ -150,8 +161,8 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter)
{
var now = DateTimeOffset.Now;
var certificateManager = new CertificateManager();
var certificates = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
var certificateManager = CertificateManager.Instance;
var certificates = certificateManager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
if (certificates.Count == 0)
{
reporter.Output("No valid certificate found.");
@ -159,21 +170,25 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
}
else
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && certificateManager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions())
foreach (var certificate in certificates)
{
reporter.Warn($"A valid HTTPS certificate was found but it may not be accessible across security partitions. Run dotnet dev-certs https to ensure it will be accessible during development.");
return ErrorMacOsCertificateKeyCouldNotBeAccessible;
}
else
{
reporter.Verbose("A valid certificate was found.");
// 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);
if (!status.Result)
{
reporter.Warn(status.Message);
return InvalidCertificateState;
}
}
reporter.Verbose("A valid certificate was found.");
}
if (trust != null && trust.HasValue())
{
var store = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? StoreName.My : StoreName.Root;
var trustedCertificates = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, store, StoreLocation.CurrentUser, isValid: true);
var trustedCertificates = certificateManager.ListCertificates(store, StoreLocation.CurrentUser, isValid: true);
if (!certificates.Any(c => certificateManager.IsTrusted(c)))
{
reporter.Output($@"The following certificates were found, but none of them is trusted:
@ -192,20 +207,24 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOption password, CommandOption trust, IReporter reporter)
{
var now = DateTimeOffset.Now;
var manager = new CertificateManager();
var manager = CertificateManager.Instance;
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && manager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions() || manager.GetHttpsCertificates().Count == 0)
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
reporter.Warn($"A valid HTTPS certificate with a key accessible across security partitions was not found. The following command will run to fix it:" + Environment.NewLine +
"'sudo security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9'" + Environment.NewLine +
"This command will make the certificate key accessible across security partitions and might prompt you for your password. For more information see: https://aka.ms/aspnetcore/2.1/troubleshootcertissues");
}
var certificates = manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, exportPath.HasValue());
foreach (var certificate in certificates)
{
var status = manager.CheckCertificateState(certificate, interactive: true);
if (!status.Result)
{
reporter.Warn("One or more certificates might be in an invalid state. We will try to access the certificate key " +
"for each certificate and as a result you might be prompted one or more times to enter " +
"your password to access the user keychain. " +
"When that happens, select 'Always Allow' to grant 'dotnet' access to the certificate key in the future.");
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && manager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions() || manager.GetHttpsCertificates().Count == 0)
{
reporter.Warn($"A valid HTTPS certificate with a key accessible across security partitions was not found. The following command will run to fix it:" + Environment.NewLine +
"'sudo security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9'" + Environment.NewLine +
"This command will make the certificate key accessible across security partitions and might prompt you for your password. For more information see: https://aka.ms/aspnetcore/3.1/troubleshootcertissues");
break;
}
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && trust?.HasValue() == true)
@ -231,9 +250,7 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
password.HasValue(),
password.Value());
reporter.Verbose(string.Join(Environment.NewLine, result.Diagnostics.Messages));
switch (result.ResultCode)
switch (result)
{
case EnsureCertificateResult.Succeeded:
reporter.Output("The HTTPS developer certificate was generated successfully.");