diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index 491eb5adb974..1d8c713a1e88 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -9,6 +9,9 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; #nullable enable @@ -16,7 +19,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation; internal abstract class CertificateManager { - internal const int CurrentAspNetCoreCertificateVersion = 2; + internal const int CurrentAspNetCoreCertificateVersion = 3; + internal const int CurrentMinimumAspNetCoreCertificateVersion = 3; // OID used for HTTPS certs internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; @@ -24,7 +28,12 @@ internal abstract class CertificateManager private const string ServerAuthenticationEnhancedKeyUsageOid = "1.3.6.1.5.5.7.3.1"; private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; + + // dns names of the host from a container + private const string LocalHostDockerHttpsDnsName = "host.docker.internal"; + private const string ContainersDockerHttpsDnsName = "host.containers.internal"; + // main cert subject private const string LocalhostHttpsDnsName = "localhost"; internal const string LocalhostHttpsDistinguishedName = "CN=" + LocalhostHttpsDnsName; @@ -46,7 +55,28 @@ public int AspNetHttpsCertificateVersion { get; // For testing purposes only - internal set; + internal set + { + ArgumentOutOfRangeException.ThrowIfLessThan( + value, + MinimumAspNetHttpsCertificateVersion, + $"{nameof(AspNetHttpsCertificateVersion)} cannot be lesser than {nameof(MinimumAspNetHttpsCertificateVersion)}"); + field = value; + } + } + + public int MinimumAspNetHttpsCertificateVersion + { + get; + // For testing purposes only + internal set + { + ArgumentOutOfRangeException.ThrowIfGreaterThan( + value, + AspNetHttpsCertificateVersion, + $"{nameof(MinimumAspNetHttpsCertificateVersion)} cannot be greater than {nameof(AspNetHttpsCertificateVersion)}"); + field = value; + } } public string Subject { get; } @@ -57,9 +87,16 @@ public CertificateManager() : this(LocalhostHttpsDistinguishedName, CurrentAspNe // For testing purposes only internal CertificateManager(string subject, int version) + : this(subject, version, version) + { + } + + // For testing purposes only + internal CertificateManager(string subject, int generatedVersion, int minimumVersion) { Subject = subject; - AspNetHttpsCertificateVersion = version; + AspNetHttpsCertificateVersion = generatedVersion; + MinimumAspNetHttpsCertificateVersion = minimumVersion; } /// <remarks> @@ -147,30 +184,30 @@ bool HasOid(X509Certificate2 certificate, string oid) => certificate.Extensions.OfType<X509Extension>() .Any(e => string.Equals(oid, e.Oid?.Value, StringComparison.Ordinal)); - static byte GetCertificateVersion(X509Certificate2 c) - { - var byteArray = c.Extensions.OfType<X509Extension>() - .Where(e => string.Equals(AspNetHttpsOid, e.Oid?.Value, StringComparison.Ordinal)) - .Single() - .RawData; - - if ((byteArray.Length == AspNetHttpsOidFriendlyName.Length && byteArray[0] == (byte)'A') || byteArray.Length == 0) - { - // No Version set, default to 0 - return 0b0; - } - else - { - // Version is in the only byte of the byte array. - return byteArray[0]; - } - } - bool IsValidCertificate(X509Certificate2 certificate, DateTimeOffset currentDate, bool requireExportable) => certificate.NotBefore <= currentDate && currentDate <= certificate.NotAfter && (!requireExportable || IsExportable(certificate)) && - GetCertificateVersion(certificate) >= AspNetHttpsCertificateVersion; + GetCertificateVersion(certificate) >= MinimumAspNetHttpsCertificateVersion; + } + + internal static byte GetCertificateVersion(X509Certificate2 c) + { + var byteArray = c.Extensions.OfType<X509Extension>() + .Where(e => string.Equals(AspNetHttpsOid, e.Oid?.Value, StringComparison.Ordinal)) + .Single() + .RawData; + + if ((byteArray.Length == AspNetHttpsOidFriendlyName.Length && byteArray[0] == (byte)'A') || byteArray.Length == 0) + { + // No Version set, default to 0 + return 0b0; + } + else + { + // Version is in the only byte of the byte array. + return byteArray[0]; + } } protected virtual void PopulateCertificatesFromStore(X509Store store, List<X509Certificate2> certificates, bool requireExportable) @@ -487,7 +524,7 @@ public void CleanupHttpsCertificates() /// <remarks>Implementations may choose to throw, rather than returning <see cref="TrustLevel.None"/>.</remarks> protected abstract TrustLevel TrustCertificateCore(X509Certificate2 certificate); - protected abstract bool IsExportable(X509Certificate2 c); + internal abstract bool IsExportable(X509Certificate2 c); protected abstract void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate); @@ -649,6 +686,8 @@ internal X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOf var extensions = new List<X509Extension>(); var sanBuilder = new SubjectAlternativeNameBuilder(); sanBuilder.AddDnsName(LocalhostHttpsDnsName); + sanBuilder.AddDnsName(LocalHostDockerHttpsDnsName); + sanBuilder.AddDnsName(ContainersDockerHttpsDnsName); var keyUsage = new X509KeyUsageExtension(X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, critical: true); var enhancedKeyUsage = new X509EnhancedKeyUsageExtension( diff --git a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs index a38e22762190..36b0c92d895c 100644 --- a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs @@ -302,7 +302,7 @@ private static bool IsCertOnKeychain(string keychain, X509Certificate2 certifica } // We don't have a good way of checking on the underlying implementation if it is exportable, so just return true. - protected override bool IsExportable(X509Certificate2 c) => true; + internal override bool IsExportable(X509Certificate2 c) => true; protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) { diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index 0ea92b87483e..149e0fab3ba6 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -179,7 +179,7 @@ internal override void CorrectCertificateState(X509Certificate2 candidate) // This is about correcting storage, not trust. } - protected override bool IsExportable(X509Certificate2 c) => true; + internal override bool IsExportable(X509Certificate2 c) => true; protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) { diff --git a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs index 85a72be37c66..1cf1ebd9480e 100644 --- a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs +++ b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs @@ -27,7 +27,7 @@ internal WindowsCertificateManager(string subject, int version) { } - protected override bool IsExportable(X509Certificate2 c) + internal override bool IsExportable(X509Certificate2 c) { #if XPLAT // For the first run experience we don't need to know if the certificate can be exported. diff --git a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs index bcc2d6ef05b5..37bdd2cafa0a 100644 --- a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs +++ b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs @@ -388,6 +388,7 @@ public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateIfVersionIsInc ListCertificates(); _manager.AspNetHttpsCertificateVersion = 2; + _manager.MinimumAspNetHttpsCertificateVersion = 2; var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.Empty(httpsCertificateList); @@ -400,17 +401,40 @@ public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateForEmptyVersio var now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); + _manager.MinimumAspNetHttpsCertificateVersion = 0; _manager.AspNetHttpsCertificateVersion = 0; var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); Output.WriteLine(creation.ToString()); ListCertificates(); _manager.AspNetHttpsCertificateVersion = 1; + _manager.MinimumAspNetHttpsCertificateVersion = 1; var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.Empty(httpsCertificateList); } + [Fact] + [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "All.OSX")] + public void EnsureCreateHttpsCertificate_DoNotOverrideValidOldCertificate() + { + _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(); + + // Simulate a tool with the same min version as the already existing cert but with a more + // recent generation version + _manager.MinimumAspNetHttpsCertificateVersion = 1; + _manager.AspNetHttpsCertificateVersion = 2; + var alreadyExist = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(alreadyExist.ToString()); + Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, alreadyExist); + } + [ConditionalFact] [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "All.OSX")] public void EnsureCreateHttpsCertificate_ReturnsValidIfVersionIsZero() @@ -419,7 +443,7 @@ public void EnsureCreateHttpsCertificate_ReturnsValidIfVersionIsZero() var now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - _manager.AspNetHttpsCertificateVersion = 0; + _manager.MinimumAspNetHttpsCertificateVersion = 0; var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); Output.WriteLine(creation.ToString()); ListCertificates(); @@ -441,7 +465,7 @@ public void EnsureCreateHttpsCertificate_ReturnValidIfCertIsNewer() Output.WriteLine(creation.ToString()); ListCertificates(); - _manager.AspNetHttpsCertificateVersion = 1; + _manager.MinimumAspNetHttpsCertificateVersion = 1; var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.NotEmpty(httpsCertificateList); } @@ -455,16 +479,24 @@ public void ListCertificates_AlwaysReturnsTheCertificate_WithHighestVersion() 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; + _manager.MinimumAspNetHttpsCertificateVersion = 1; var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); Output.WriteLine(creation.ToString()); ListCertificates(); _manager.AspNetHttpsCertificateVersion = 2; + _manager.MinimumAspNetHttpsCertificateVersion = 2; creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); Output.WriteLine(creation.ToString()); ListCertificates(); - _manager.AspNetHttpsCertificateVersion = 1; + _manager.AspNetHttpsCertificateVersion = 3; + _manager.MinimumAspNetHttpsCertificateVersion = 3; + creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); + + _manager.MinimumAspNetHttpsCertificateVersion = 2; var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.Equal(2, httpsCertificateList.Count); @@ -475,13 +507,13 @@ public void ListCertificates_AlwaysReturnsTheCertificate_WithHighestVersion() firstCertificate.Extensions.OfType<X509Extension>(), e => e.Critical == false && e.Oid.Value == CertificateManager.AspNetHttpsOid && - e.RawData[0] == 2); + e.RawData[0] == 3); Assert.Contains( secondCertificate.Extensions.OfType<X509Extension>(), e => e.Critical == false && e.Oid.Value == CertificateManager.AspNetHttpsOid && - e.RawData[0] == 1); + e.RawData[0] == 2); } [ConditionalFact] @@ -532,6 +564,8 @@ public CertFixture() internal void CleanupCertificates() { + Manager.MinimumAspNetHttpsCertificateVersion = 1; + Manager.AspNetHttpsCertificateVersion = 1; Manager.RemoveAllCertificates(StoreName.My, StoreLocation.CurrentUser); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index 222e3c355e57..bf6a9d964a5c 100644 --- a/src/Tools/dotnet-dev-certs/src/Program.cs +++ b/src/Tools/dotnet-dev-certs/src/Program.cs @@ -1,9 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Linq; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.Extensions.CommandLineUtils; using Microsoft.Extensions.Tools.Internal; @@ -110,6 +114,10 @@ public static int Main(string[] args) "Display warnings and errors only.", CommandOptionType.NoValue); + var checkJsonOutput = c.Option("--check-trust-machine-readable", + "Same as running --check --trust, but output the results in json.", + CommandOptionType.NoValue); + c.HelpOption("-h|--help"); c.OnExecute(() => @@ -122,9 +130,20 @@ public static int Main(string[] args) listener.EnableEvents(CertificateManager.Log, System.Diagnostics.Tracing.EventLevel.Verbose); } + if (checkJsonOutput.HasValue()) + { + if (exportPath.HasValue() || trust?.HasValue() == true || format.HasValue() || noPassword.HasValue() || check.HasValue() || clean.HasValue() || + (!import.HasValue() && password.HasValue()) || + (import.HasValue() && !password.HasValue())) + { + reporter.Error(InvalidUsageErrorMessage); + return CriticalError; + } + } + if (clean.HasValue()) { - if (exportPath.HasValue() || trust?.HasValue() == true || format.HasValue() || noPassword.HasValue() || check.HasValue() || + if (exportPath.HasValue() || trust?.HasValue() == true || format.HasValue() || noPassword.HasValue() || check.HasValue() || checkJsonOutput.HasValue() || (!import.HasValue() && password.HasValue()) || (import.HasValue() && !password.HasValue())) { @@ -135,7 +154,7 @@ public static int Main(string[] args) if (check.HasValue()) { - if (exportPath.HasValue() || password.HasValue() || noPassword.HasValue() || clean.HasValue() || format.HasValue() || import.HasValue()) + if (exportPath.HasValue() || password.HasValue() || noPassword.HasValue() || clean.HasValue() || format.HasValue() || import.HasValue() || checkJsonOutput.HasValue()) { reporter.Error(InvalidUsageErrorMessage); return CriticalError; @@ -179,6 +198,11 @@ public static int Main(string[] args) return ImportCertificate(import, password, reporter); } + if (checkJsonOutput.HasValue()) + { + return CheckHttpsCertificateJsonOutput(reporter); + } + return EnsureHttpsCertificate(exportPath, password, noPassword, trust, format, reporter); }); }); @@ -335,6 +359,16 @@ private static void ReportCertificates(IReporter reporter, IReadOnlyList<X509Cer }); } + private static int CheckHttpsCertificateJsonOutput(IReporter reporter) + { + var availableCertificates = CertificateManager.Instance.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); + + var certReports = availableCertificates.Select(CertificateReport.FromX509Certificate2).ToList(); + reporter.Output(JsonSerializer.Serialize(certReports, options: new JsonSerializerOptions { WriteIndented = true })); + + return Success; + } + private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOption password, CommandOption noPassword, CommandOption trust, CommandOption exportFormat, IReporter reporter) { var now = DateTimeOffset.Now; @@ -452,3 +486,63 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio } } } + +/// <summary> +/// A Serializable friendly version of the cert report output +/// </summary> +internal class CertificateReport +{ + public string Thumbprint { get; init; } + public string Subject { get; init; } + public List<string> X509SubjectAlternativeNameExtension { get; init; } + public int Version { get; init; } + public DateTime ValidityNotBefore { get; init; } + public DateTime ValidityNotAfter { get; init; } + public bool IsHttpsDevelopmentCertificate { get; init; } + public bool IsExportable { get; init; } + public string TrustLevel { get; private set; } + + public static CertificateReport FromX509Certificate2(X509Certificate2 cert) + { + var certificateManager = CertificateManager.Instance; + var status = certificateManager.CheckCertificateState(cert); + string statusString; + if (!status.Success) + { + statusString = "Invalid"; + } + else + { + var trustStatus = certificateManager.GetTrustLevel(cert); + statusString = trustStatus.ToString(); + } + return new CertificateReport + { + Thumbprint = cert.Thumbprint, + Subject = cert.Subject, + X509SubjectAlternativeNameExtension = GetSanExtension(cert), + Version = CertificateManager.GetCertificateVersion(cert), + ValidityNotBefore = cert.NotBefore, + ValidityNotAfter = cert.NotAfter, + IsHttpsDevelopmentCertificate = CertificateManager.IsHttpsDevelopmentCertificate(cert), + IsExportable = certificateManager.IsExportable(cert), + TrustLevel = statusString + }; + + static List<string> GetSanExtension(X509Certificate2 cert) + { + var dnsNames = new List<string>(); + foreach (var extension in cert.Extensions) + { + if (extension is X509SubjectAlternativeNameExtension sanExtension) + { + foreach (var dns in sanExtension.EnumerateDnsNames()) + { + dnsNames.Add(dns); + } + } + } + return dnsNames; + } + } +}