Skip to content
98 changes: 91 additions & 7 deletions src/Shared/CertificateGeneration/CertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ namespace Microsoft.AspNetCore.Certificates.Generation;

internal abstract class CertificateManager
{
// This is the version of the ASP.NET Core HTTPS development certificate that will be generated by tooling built with this version of the library.
// Increment this when making any structural changes to the generated certificate (e.g. changing extensions, key usages, SANs, etc.).
// Version 6 was introduced in SDK 10.0.102 and runtime 10.0.2.
Comment thread
danegsta marked this conversation as resolved.
internal const int CurrentAspNetCoreCertificateVersion = 6;
internal const int CurrentMinimumAspNetCoreCertificateVersion = 6;
// This is the minimum version of the certificate that will be considered valid by runtime components built using this version of the library.
// Increment this only when making breaking changes to the certificate or during major runtime version increments. Must always be less than or equal to CurrentAspNetCoreCertificateVersion.
// This determines the minimum version of the tooling required to generate a certificate that will be considered valid by the runtime.
// Version 4 was introduced in SDK 10.0.100 and runtime 10.0.0.
Comment thread
danegsta marked this conversation as resolved.
internal const int CurrentMinimumAspNetCoreCertificateVersion = 4;
Comment thread
danegsta marked this conversation as resolved.

// OID used for HTTPS certs
internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1";
Expand Down Expand Up @@ -89,7 +96,7 @@ internal set

public string Subject { get; }

public CertificateManager() : this(LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion)
public CertificateManager() : this(LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion, CurrentMinimumAspNetCoreCertificateVersion)
{
}

Expand Down Expand Up @@ -163,7 +170,19 @@ public IList<X509Certificate2> ListCertificates(
Log.DescribeInvalidCertificates(ToCertificateDescription(invalidCertificates));
}

matchingCertificates = validCertificates;
// Ensure the certificate meets the minimum version requirement.
var validMinVersionCertificates = validCertificates
.Where(c => GetCertificateVersion(c) >= MinimumAspNetHttpsCertificateVersion)
.ToArray();

if (Log.IsEnabled())
{
var belowMinimumVersionCertificates = validCertificates.Except(validMinVersionCertificates);
Log.DescribeMinimumVersionCertificates(ToCertificateDescription(validMinVersionCertificates));
Log.DescribeBelowMinimumVersionCertificates(ToCertificateDescription(belowMinimumVersionCertificates));
}

matchingCertificates = validMinVersionCertificates;
}

// We need to enumerate the certificates early to prevent disposing issues.
Expand Down Expand Up @@ -191,12 +210,20 @@ public IList<X509Certificate2> ListCertificates(
bool HasOid(X509Certificate2 certificate, string oid) =>
certificate.Extensions.OfType<X509Extension>()
.Any(e => string.Equals(oid, e.Oid?.Value, StringComparison.Ordinal));
}

bool IsValidCertificate(X509Certificate2 certificate, DateTimeOffset currentDate, bool requireExportable) =>
certificate.NotBefore <= currentDate &&
/// <summary>
/// Validate that the certificate is valid at the given date and time (and exportable if required).
/// </summary>
/// <param name="certificate">The certificate to validate.</param>
/// <param name="currentDate">The current date to validate against.</param>
/// <param name="requireExportable">Whether the certificate must be exportable.</param>
/// <returns>True if the certificate is valid; otherwise, false.</returns>
internal bool IsValidCertificate(X509Certificate2 certificate, DateTimeOffset currentDate, bool requireExportable)
{
return certificate.NotBefore <= currentDate &&
currentDate <= certificate.NotAfter &&
(!requireExportable || IsExportable(certificate)) &&
GetCertificateVersion(certificate) >= MinimumAspNetHttpsCertificateVersion;
(!requireExportable || IsExportable(certificate));
}

internal static byte GetCertificateVersion(X509Certificate2 c)
Expand Down Expand Up @@ -226,6 +253,27 @@ protected virtual void PopulateCertificatesFromStore(X509Store store, List<X509C
public IList<X509Certificate2> GetHttpsCertificates() =>
ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true);

/// <summary>
/// Ensures that a valid ASP.NET Core HTTPS development certificate is present.
/// </summary>
/// <param name="notBefore">The date and time before which the certificate is not valid.</param>
/// <param name="notAfter">The date and time after which the certificate is not valid.</param>
/// <param name="path">Path to export the certificate (directory must exist).</param>
/// <param name="trust">Whether to trust the certificate or simply add it to the CurrentUser/My store.</param>
/// <param name="includePrivateKey">Whether to include the private key in the exported certificate.</param>
/// <param name="password">Password for the exported certificate.</param>
/// <param name="keyExportFormat">Format for exporting the certificate key.</param>
/// <param name="isInteractive">Whether the operation is interactive (dotnet dev-certs tool) or non-interactive (first run experience).</param>
/// <returns>The result of the ensure operation.</returns>
/// <exception cref="InvalidOperationException">There was an error ensuring the certificate exists.</exception>
/// <remarks>
/// The minimum certificate version checks behave differently based on whether the operation is interactive or not. In interactive mode,
/// the certificate will only be considered valid if it meets or exceeds the current version of the certificate. In non-interactive mode,
/// the certificate will be considered valid as long as it meets the minimum supported version requirement. This is to allow first run
/// to upgrade a certificate if it becomes necessary to bump the minimum version due to security issues, etc. while not leaving users with
/// a partially valid certificate after a normal first run experience. Interactive scenarios such as the dotnet dev-certs tool should always
/// ensure the certificate is updated to at least the latest supported version.
/// </remarks>
public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
DateTimeOffset notBefore,
DateTimeOffset notAfter,
Expand All @@ -243,6 +291,15 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
var certificates = currentUserCertificates.Concat(localMachineCertificates);

var filteredCertificates = certificates.Where(c => c.Subject == Subject);
if (isInteractive)
{
// For purposes of updating the dev cert, only consider certificates with the current version or higher as valid
// Only applies to interactive scenarios where we want to ensure we're generating the latest certificate
// For non-interactive scenarios (e.g. first run experience), we want to accept older versions of the certificate as long as they meet the minimum version requirement
// This will allow us to respond to scenarios where we need to invalidate older certificates due to security issues, etc. but not leave users
// with a partially valid certificate after their first run experience.
filteredCertificates = filteredCertificates.Where(c => GetCertificateVersion(c) >= AspNetHttpsCertificateVersion);
}

if (Log.IsEnabled())
{
Expand Down Expand Up @@ -688,6 +745,15 @@ internal void ExportCertificate(X509Certificate2 certificate, string path, bool
}
}

/// <summary>
/// Creates a new ASP.NET Core HTTPS development certificate.
/// </summary>
/// <param name="notBefore">The date and time before which the certificate is not valid.</param>
/// <param name="notAfter">The date and time after which the certificate is not valid.</param>
/// <returns>The created X509Certificate2 instance.</returns>
/// <remarks>
/// When making changes to the certificate generated by this method, ensure that the <see cref="CurrentAspNetCoreCertificateVersion"/> constant is updated accordingly.
/// </remarks>
internal X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter)
{
var subject = new X500DistinguishedName(Subject);
Expand Down Expand Up @@ -820,6 +886,18 @@ internal void RemoveCertificate(X509Certificate2 certificate, RemoveLocations lo

internal abstract void CorrectCertificateState(X509Certificate2 candidate);

/// <summary>
/// Creates a self-signed certificate with the specified subject, extensions, and validity period.
/// </summary>
/// <param name="subject">The subject distinguished name for the certificate.</param>
/// <param name="extensions">The collection of X509 extensions to include in the certificate.</param>
/// <param name="notBefore">The date and time before which the certificate is not valid.</param>
/// <param name="notAfter">The date and time after which the certificate is not valid.</param>
/// <returns>The created X509Certificate2 instance.</returns>
/// <exception cref="InvalidOperationException">If a key with the specified minimum size cannot be created.</exception>
/// <remarks>
/// If making changes to the certificate generated by this method, ensure that the <see cref="CurrentAspNetCoreCertificateVersion"/> constant is updated accordingly.
/// </remarks>
internal static X509Certificate2 CreateSelfSignedCertificate(
X500DistinguishedName subject,
IEnumerable<X509Extension> extensions,
Expand Down Expand Up @@ -1327,6 +1405,12 @@ public sealed class CertificateManagerEventSource : EventSource

[Event(117, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the Windows certificate store via WSL: {0}.")]
internal void WslWindowsTrustException(string exceptionMessage) => WriteEvent(117, exceptionMessage);

[Event(118, Level = EventLevel.Verbose, Message = "Meets minimum version certificates: {0}")]
public void DescribeMinimumVersionCertificates(string meetsMinimumVersionCertificates) => WriteEvent(118, meetsMinimumVersionCertificates);

[Event(119, Level = EventLevel.Verbose, Message = "Below minimum version certificates: {0}")]
public void DescribeBelowMinimumVersionCertificates(string belowMinimumVersionCertificates) => WriteEvent(119, belowMinimumVersionCertificates);
}

internal sealed class UserCancelledTrustException : Exception
Expand Down
127 changes: 127 additions & 0 deletions src/Shared/test/Shared.Tests/CertificateManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,131 @@ public void CreateSelfSignedCertificate_NoSubjectKeyIdentifierExtension()
// The Authority Key Identifier should match the Subject Key Identifier
Assert.True(authorityKeyIdentifier.KeyIdentifier?.Span.SequenceEqual(subjectKeyIdentifier.SubjectKeyIdentifierBytes.Span));
}

[Fact]
public void ListCertificates_RespectsMinimumCertificateVersion()
{
var now = DateTimeOffset.Now;
var notBefore = now.AddMinutes(-5);
var notAfter = now.AddMinutes(5);

var manager = new TestCertificateManager(generatedVersion: 6, minimumVersion: 4);

var v3Certificate = manager.CreateDevelopmentCertificateWithVersion(3, notBefore, notAfter);
var v4Certificate = manager.CreateDevelopmentCertificateWithVersion(4, notBefore, notAfter);
var v6Certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter);

manager.AddCertificate(StoreName.My, StoreLocation.CurrentUser, v3Certificate, isExportable: true);
manager.AddCertificate(StoreName.My, StoreLocation.CurrentUser, v4Certificate, isExportable: true);
manager.AddCertificate(StoreName.My, StoreLocation.CurrentUser, v6Certificate, isExportable: true);

var certificates = manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true);

Assert.DoesNotContain(certificates, cert => CertificateManager.GetCertificateVersion(cert) < manager.MinimumAspNetHttpsCertificateVersion);
Assert.Contains(certificates, cert => CertificateManager.GetCertificateVersion(cert) == 4);
Assert.Contains(certificates, cert => CertificateManager.GetCertificateVersion(cert) == 6);
}

[Fact]
public void EnsureAspNetCoreHttpsDevelopmentCertificate_InteractiveUsesCurrentVersionWhenSelectingCertificate()
{
var now = DateTimeOffset.Now;
var notBefore = now.AddMinutes(-5);
var notAfter = now.AddMinutes(5);

var manager = new TestCertificateManager(generatedVersion: 6, minimumVersion: 4);
var olderCertificate = manager.CreateDevelopmentCertificateWithVersion(5, notBefore, notAfter);

manager.AddCertificate(StoreName.My, StoreLocation.CurrentUser, olderCertificate, isExportable: true);

var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter, isInteractive: true);

Assert.Equal(EnsureCertificateResult.Succeeded, result);

var certificates = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser);
Assert.Contains(certificates, cert => CertificateManager.GetCertificateVersion(cert) == 6);
}

[Fact]
public void EnsureAspNetCoreHttpsDevelopmentCertificate_NonInteractiveAcceptsMinimumVersionCertificate()
{
var now = DateTimeOffset.Now;
var notBefore = now.AddMinutes(-5);
var notAfter = now.AddMinutes(5);

var manager = new TestCertificateManager(generatedVersion: 6, minimumVersion: 4);
var minimumCertificate = manager.CreateDevelopmentCertificateWithVersion(4, notBefore, notAfter);
manager.AddCertificate(StoreName.My, StoreLocation.CurrentUser, minimumCertificate, isExportable: true);

var beforeCount = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser).Count;
var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter, isInteractive: false);
var afterCount = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser).Count;

Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result);
Assert.Equal(beforeCount, afterCount);
Assert.DoesNotContain(manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser),
cert => CertificateManager.GetCertificateVersion(cert) == 6);
}

[Fact]
public void EnsureAspNetCoreHttpsDevelopmentCertificate_InteractiveCreatesWhenOnlyMinimumVersionExists()
{
var now = DateTimeOffset.Now;
var notBefore = now.AddMinutes(-5);
var notAfter = now.AddMinutes(5);

var manager = new TestCertificateManager(generatedVersion: 6, minimumVersion: 4);
var minimumCertificate = manager.CreateDevelopmentCertificateWithVersion(4, notBefore, notAfter);
manager.AddCertificate(StoreName.My, StoreLocation.CurrentUser, minimumCertificate, isExportable: true);

var beforeCount = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser).Count;
var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter, isInteractive: true);
var afterCount = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser).Count;
var certificates = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser);

Assert.Equal(EnsureCertificateResult.Succeeded, result);
Assert.Equal(beforeCount + 1, afterCount);
Assert.Contains(certificates, cert => CertificateManager.GetCertificateVersion(cert) == 4);
Assert.Contains(certificates, cert => CertificateManager.GetCertificateVersion(cert) == 6);
}

[Fact]
public void EnsureAspNetCoreHttpsDevelopmentCertificate_DoesNotCreateWhenCurrentVersionExists()
{
var now = DateTimeOffset.Now;
var notBefore = now.AddMinutes(-5);
var notAfter = now.AddMinutes(5);

var manager = new TestCertificateManager(generatedVersion: 6, minimumVersion: 4);
var currentCertificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter);
manager.AddCertificate(StoreName.My, StoreLocation.CurrentUser, currentCertificate, isExportable: true);

var beforeCount = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser).Count;
var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter, isInteractive: false);
var afterCount = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser).Count;

Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result);
Assert.Equal(beforeCount, afterCount);
}

[Fact]
public void EnsureAspNetCoreHttpsDevelopmentCertificate_DoesNotCreateWhenNewerVersionExists()
{
var now = DateTimeOffset.Now;
var notBefore = now.AddMinutes(-5);
var notAfter = now.AddMinutes(5);

var manager = new TestCertificateManager(generatedVersion: 6, minimumVersion: 4);
var newerCertificate = manager.CreateDevelopmentCertificateWithVersion(7, notBefore, notAfter);
manager.AddCertificate(StoreName.My, StoreLocation.CurrentUser, newerCertificate, isExportable: true);

var beforeCount = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser).Count;
var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter, isInteractive: false);
var afterCount = manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser).Count;

Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result);
Assert.Equal(beforeCount, afterCount);
Assert.Contains(manager.GetStoreCertificates(StoreName.My, StoreLocation.CurrentUser),
cert => CertificateManager.GetCertificateVersion(cert) == 7);
}
}
Loading
Loading