[Https] Various improvements to the dev-certs tool (#25037)

* Add support for the trust option on Linux on the command-line tool and print a message when it's used pointing to docs.
* Bump the certificate version to 2 to ensure that the certificate gets updated for 5.0 on Mac OS.
* Ensure we always select the certificate with the highest available version to ensure that when we change the certificate in the future older runtimes pick up the new certificate.
* Support exporting the certificate without key on PEM format.
This commit is contained in:
Javier Calvarro Nelson 2020-08-20 18:55:56 +02:00 committed by GitHub
parent 818279f1f5
commit 3c34c3ab0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 132 additions and 38 deletions

View File

@ -15,6 +15,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation
{ {
internal abstract class CertificateManager internal abstract class CertificateManager
{ {
internal const int CurrentAspNetCoreCertificateVersion = 2;
internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1";
internal const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate"; internal const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate";
@ -45,7 +46,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation
public string Subject { get; } public string Subject { get; }
public CertificateManager() : this(LocalhostHttpsDistinguishedName, 1) public CertificateManager() : this(LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion)
{ {
} }
@ -86,10 +87,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation
Log.CheckCertificatesValidity(); Log.CheckCertificatesValidity();
var now = DateTimeOffset.Now; var now = DateTimeOffset.Now;
var validCertificates = matchingCertificates var validCertificates = matchingCertificates
.Where(c => c.NotBefore <= now && .Where(c => IsValidCertificate(c, now, requireExportable))
now <= c.NotAfter && .OrderByDescending(c => GetCertificateVersion(c))
(!requireExportable || IsExportable(c))
&& MatchesVersion(c))
.ToArray(); .ToArray();
var invalidCertificates = matchingCertificates.Except(validCertificates); var invalidCertificates = matchingCertificates.Except(validCertificates);
@ -123,7 +122,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation
certificate.Extensions.OfType<X509Extension>() certificate.Extensions.OfType<X509Extension>()
.Any(e => string.Equals(oid, e.Oid.Value, StringComparison.Ordinal)); .Any(e => string.Equals(oid, e.Oid.Value, StringComparison.Ordinal));
bool MatchesVersion(X509Certificate2 c) static byte GetCertificateVersion(X509Certificate2 c)
{ {
var byteArray = c.Extensions.OfType<X509Extension>() var byteArray = c.Extensions.OfType<X509Extension>()
.Where(e => string.Equals(AspNetHttpsOid, e.Oid.Value, StringComparison.Ordinal)) .Where(e => string.Equals(AspNetHttpsOid, e.Oid.Value, StringComparison.Ordinal))
@ -133,14 +132,20 @@ namespace Microsoft.AspNetCore.Certificates.Generation
if ((byteArray.Length == AspNetHttpsOidFriendlyName.Length && byteArray[0] == (byte)'A') || byteArray.Length == 0) if ((byteArray.Length == AspNetHttpsOidFriendlyName.Length && byteArray[0] == (byte)'A') || byteArray.Length == 0)
{ {
// No Version set, default to 0 // No Version set, default to 0
return 0 >= AspNetHttpsCertificateVersion; return 0b0;
} }
else else
{ {
// Version is in the only byte of the byte array. // Version is in the only byte of the byte array.
return byteArray[0] >= AspNetHttpsCertificateVersion; return byteArray[0];
} }
} }
bool IsValidCertificate(X509Certificate2 certificate, DateTimeOffset currentDate, bool requireExportable) =>
certificate.NotBefore <= currentDate &&
currentDate <= certificate.NotAfter &&
(!requireExportable || IsExportable(certificate)) &&
GetCertificateVersion(certificate) >= AspNetHttpsCertificateVersion;
} }
public IList<X509Certificate2> GetHttpsCertificates() => public IList<X509Certificate2> GetHttpsCertificates() =>
@ -448,7 +453,14 @@ namespace Microsoft.AspNetCore.Certificates.Generation
} }
else else
{ {
bytes = certificate.Export(X509ContentType.Cert); if (format == CertificateKeyExportFormat.Pem)
{
bytes = Encoding.ASCII.GetBytes(PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert)));
}
else
{
bytes = certificate.Export(X509ContentType.Cert);
}
} }
} }
catch (Exception e) catch (Exception e)

View File

@ -191,6 +191,36 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString());
} }
[ConditionalFact]
[SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")]
public void EnsureCreateHttpsCertificate_CanExportTheCertInPemFormat_WithoutKey()
{
// Arrange
const string CertificateName = nameof(EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates) + ".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();
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: false, password: null, keyExportFormat: CertificateKeyExportFormat.Pem, isInteractive: false);
// Assert
Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result);
Assert.True(File.Exists(CertificateName));
var exportedCertificate = new X509Certificate2(CertificateName);
Assert.NotNull(exportedCertificate);
Assert.False(exportedCertificate.HasPrivateKey);
}
[ConditionalFact] [ConditionalFact]
[SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")] [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")]
public void EnsureCreateHttpsCertificate_CanImport_ExportedPfx() public void EnsureCreateHttpsCertificate_CanImport_ExportedPfx()
@ -351,6 +381,44 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
Assert.NotEmpty(httpsCertificateList); Assert.NotEmpty(httpsCertificateList);
} }
[ConditionalFact]
[SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")]
public void ListCertificates_AlwaysReturnsTheCertificate_WithHighestVersion()
{
_fixture.CleanupCertificates();
var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
_manager.AspNetHttpsCertificateVersion = 1;
var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false);
Output.WriteLine(creation.ToString());
ListCertificates();
_manager.AspNetHttpsCertificateVersion = 2;
creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false);
Output.WriteLine(creation.ToString());
ListCertificates();
_manager.AspNetHttpsCertificateVersion = 1;
var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
Assert.Equal(2, httpsCertificateList.Count);
var firstCertificate = httpsCertificateList[0];
var secondCertificate = httpsCertificateList[1];
Assert.Contains(
firstCertificate.Extensions.OfType<X509Extension>(),
e => e.Critical == false &&
e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" &&
e.RawData[0] == 2);
Assert.Contains(
secondCertificate.Extensions.OfType<X509Extension>(),
e => e.Critical == false &&
e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" &&
e.RawData[0] == 1);
}
} }
public class CertFixture : IDisposable public class CertFixture : IDisposable

View File

@ -98,12 +98,9 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
CommandOptionType.SingleValue); CommandOptionType.SingleValue);
CommandOption trust = null; CommandOption trust = null;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) trust = c.Option("-t|--trust",
{ "Trust the certificate on the current platform",
trust = c.Option("-t|--trust", CommandOptionType.NoValue);
"Trust the certificate on the current platform",
CommandOptionType.NoValue);
}
var verbose = c.Option("-v|--verbose", var verbose = c.Option("-v|--verbose",
"Display more debug information.", "Display more debug information.",
@ -292,24 +289,32 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
if (trust != null && trust.HasValue()) if (trust != null && trust.HasValue())
{ {
var store = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? StoreName.My : StoreName.Root; if(!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
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: var store = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? StoreName.My : StoreName.Root;
{string.Join(Environment.NewLine, certificates.Select(c => $"{c.Subject} - {c.Thumbprint}"))}"); var trustedCertificates = certificateManager.ListCertificates(store, StoreLocation.CurrentUser, isValid: true);
return ErrorCertificateNotTrusted; if (!certificates.Any(c => certificateManager.IsTrusted(c)))
{
reporter.Output($@"The following certificates were found, but none of them is trusted:
{string.Join(Environment.NewLine, certificates.Select(c => $"{c.Subject} - {c.Thumbprint}"))}");
return ErrorCertificateNotTrusted;
}
else
{
reporter.Output("A trusted certificate was found.");
}
} }
else else
{ {
reporter.Output("A trusted certificate was found."); 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");
} }
} }
return Success; return Success;
} }
private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOption password, CommandOption noPassword, CommandOption trust, CommandOption keyFormat, IReporter reporter) private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOption password, CommandOption noPassword, CommandOption trust, CommandOption exportFormat, IReporter reporter)
{ {
var now = DateTimeOffset.Now; var now = DateTimeOffset.Now;
var manager = CertificateManager.Instance; var manager = CertificateManager.Instance;
@ -332,25 +337,34 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
} }
} }
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && trust?.HasValue() == true) if (trust?.HasValue() == true)
{ {
reporter.Warn("Trusting the HTTPS development certificate was requested. If the certificate is not " + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
"already trusted we will run the following command:" + Environment.NewLine + {
"'sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain <<certificate>>'" + reporter.Warn("Trusting the HTTPS development certificate was requested. If the certificate is not " +
Environment.NewLine + "This command might prompt you for your password to install the certificate " + "already trusted we will run the following command:" + Environment.NewLine +
"on the system keychain."); "'sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain <<certificate>>'" +
} Environment.NewLine + "This command might prompt you for your password to install the certificate " +
"on the system keychain.");
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && trust?.HasValue() == true) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
reporter.Warn("Trusting the HTTPS development certificate was requested. A confirmation prompt will be displayed " + reporter.Warn("Trusting the HTTPS development certificate was requested. A confirmation prompt will be displayed " +
"if the certificate was not previously trusted. Click yes on the prompt to trust the certificate."); "if the certificate was not previously trusted. Click yes on the prompt to trust the certificate.");
}
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");
}
} }
var format = CertificateKeyExportFormat.Pfx; var format = CertificateKeyExportFormat.Pfx;
if (keyFormat.HasValue() && !Enum.TryParse(keyFormat.Value(), ignoreCase: true, out format)) if (exportFormat.HasValue() && !Enum.TryParse(exportFormat.Value(), ignoreCase: true, out format))
{ {
reporter.Error($"Unknown key format '{keyFormat.Value()}'."); reporter.Error($"Unknown key format '{exportFormat.Value()}'.");
return InvalidKeyExportFormat; return InvalidKeyExportFormat;
} }
@ -358,10 +372,10 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
now, now,
now.Add(HttpsCertificateValidity), now.Add(HttpsCertificateValidity),
exportPath.Value(), exportPath.Value(),
trust == null ? false : trust.HasValue(), trust == null ? false : trust.HasValue() && !RuntimeInformation.IsOSPlatform(OSPlatform.Linux),
password.HasValue() || (noPassword.HasValue() && format == CertificateKeyExportFormat.Pem), password.HasValue() || (noPassword.HasValue() && format == CertificateKeyExportFormat.Pem),
password.Value(), password.Value(),
keyFormat.HasValue() ? format : CertificateKeyExportFormat.Pfx); exportFormat.HasValue() ? format : CertificateKeyExportFormat.Pfx);
switch (result) switch (result)
{ {