Skip to content
Merged
87 changes: 63 additions & 24 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 @@ -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; }
Expand All @@ -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>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(
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
44 changes: 39 additions & 5 deletions src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

[ConditionalFact]
[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()
Expand All @@ -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();
Expand All @@ -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);
}
Expand All @@ -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);

Expand All @@ -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]
Expand Down Expand Up @@ -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))
{
Expand Down
Loading
Loading