Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add host.docker.internal and host.containers.internal to the dev cert SAN #61265

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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());
Copy link
Preview

Copilot AI Apr 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review the change in filtering on OSX: switching isValid from true to false may result in listing certificates that are not currently valid, which could lead to unintended behavior.

Copilot is powered by AI, so mistakes are possible. Review output carefully before use.

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