Skip to content
71 changes: 48 additions & 23 deletions src/Shared/CertificateGeneration/CertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,31 @@
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

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";
internal const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate";

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;

Expand All @@ -49,6 +58,13 @@ public int AspNetHttpsCertificateVersion
internal set;
}

public int MinimumAspNetHttpsCertificateVersion
{
get;
// For testing purposes only
internal set;
}

public string Subject { get; }

public CertificateManager() : this(LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion)
Expand All @@ -57,9 +73,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>
Expand Down Expand Up @@ -147,30 +170,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)
Expand Down Expand Up @@ -487,7 +510,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);

Expand Down Expand Up @@ -649,6 +672,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Shared/CertificateGeneration/UnixCertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateIfVersionIsInc
Output.WriteLine(creation.ToString());
ListCertificates();

_manager.AspNetHttpsCertificateVersion = 2;
_manager.MinimumAspNetHttpsCertificateVersion = 2;

var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
Assert.Empty(httpsCertificateList);
Expand Down Expand Up @@ -419,7 +419,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();
Expand Down Expand Up @@ -460,11 +460,12 @@ public void ListCertificates_AlwaysReturnsTheCertificate_WithHighestVersion()
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.MinimumAspNetHttpsCertificateVersion = 1;
var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
Assert.Equal(2, httpsCertificateList.Count);

Expand Down Expand Up @@ -532,6 +533,8 @@ public CertFixture()

internal void CleanupCertificates()
{
Manager.AspNetHttpsCertificateVersion = 1;
Manager.MinimumAspNetHttpsCertificateVersion = 1;
Manager.RemoveAllCertificates(StoreName.My, StoreLocation.CurrentUser);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Expand Down
100 changes: 97 additions & 3 deletions src/Tools/dotnet-dev-certs/src/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -110,6 +114,10 @@ public static int Main(string[] args)
"Display warnings and errors only.",
CommandOptionType.NoValue);

var checkJsonOutput = c.Option("--check-json-output",
"Same as running --check --trust, but output the results in json.",
CommandOptionType.NoValue);

c.HelpOption("-h|--help");

c.OnExecute(() =>
Expand All @@ -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()))
{
Expand All @@ -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;
Expand Down Expand Up @@ -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);
});
});
Expand Down Expand Up @@ -335,14 +359,24 @@ 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;
var manager = CertificateManager.Instance;

if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
var certificates = manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, exportPath.HasValue());
var certificates = manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, exportPath.HasValue());
foreach (var certificate in certificates)
{
var status = manager.CheckCertificateState(certificate);
Expand Down Expand Up @@ -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;
}
}
}
Loading