[Platform] Detect and fix certificates with potentially inaccessible keys on Mac OS (3.1) (#17581)
* [Platform] Add logic to dotnet-dev-certs to detect and fix certificates with inaccessible keys on Mac OS * Update the docs link
This commit is contained in:
parent
4682c2a6dc
commit
8e5767bdf1
|
|
@ -41,6 +41,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation
|
|||
private const string MacOSTrustCertificateCommandLine = "sudo";
|
||||
private static readonly string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " ";
|
||||
private const int UserCancelledErrorCode = 1223;
|
||||
private const string MacOSSetPartitionKeyPermissionsCommandLine = "sudo";
|
||||
private static readonly string MacOSSetPartitionKeyPermissionsCommandLineArguments = "security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9 " + MacOSUserKeyChain;
|
||||
|
||||
// Setting to 0 means we don't append the version byte,
|
||||
// which is what all machines currently have.
|
||||
|
|
@ -177,6 +179,27 @@ namespace Microsoft.AspNetCore.Certificates.Generation
|
|||
}
|
||||
}
|
||||
|
||||
internal bool HasValidCertificateWithInnaccessibleKeyAcrossPartitions()
|
||||
{
|
||||
var certificates = GetHttpsCertificates();
|
||||
if (certificates.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// We need to check all certificates as a new one might be created that hasn't been correctly setup.
|
||||
var result = false;
|
||||
foreach (var certificate in certificates)
|
||||
{
|
||||
result = result || !CanAccessCertificateKeyAcrossPartitions(certificate);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public IList<X509Certificate2> GetHttpsCertificates() =>
|
||||
ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true);
|
||||
|
||||
public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter, string subjectOverride, DiagnosticInformation diagnostics = null)
|
||||
{
|
||||
var subject = new X500DistinguishedName(subjectOverride ?? LocalhostHttpsDistinguishedName);
|
||||
|
|
@ -707,9 +730,10 @@ namespace Microsoft.AspNetCore.Certificates.Generation
|
|||
bool trust = false,
|
||||
bool includePrivateKey = false,
|
||||
string password = null,
|
||||
string subject = LocalhostHttpsDistinguishedName)
|
||||
string subject = LocalhostHttpsDistinguishedName,
|
||||
bool isInteractive = true)
|
||||
{
|
||||
return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject);
|
||||
return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject, isInteractive);
|
||||
}
|
||||
|
||||
public DetailedEnsureCertificateResult EnsureValidCertificateExists(
|
||||
|
|
@ -720,7 +744,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation
|
|||
bool trust,
|
||||
bool includePrivateKey,
|
||||
string password,
|
||||
string subject)
|
||||
string subject,
|
||||
bool isInteractive)
|
||||
{
|
||||
if (purpose == CertificatePurpose.All)
|
||||
{
|
||||
|
|
@ -747,6 +772,35 @@ namespace Microsoft.AspNetCore.Certificates.Generation
|
|||
result.Diagnostics.Debug("Skipped filtering certificates by subject.");
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
foreach (var cert in filteredCertificates)
|
||||
{
|
||||
if (!CanAccessCertificateKeyAcrossPartitions(cert))
|
||||
{
|
||||
if (!isInteractive)
|
||||
{
|
||||
// If the process is not interactive (first run experience) bail out. We will simply create a certificate
|
||||
// in case there is none or report success during the first run experience.
|
||||
break;
|
||||
}
|
||||
try
|
||||
{
|
||||
// The command we run handles making keys for all localhost certificates accessible across partitions. If it can not run the
|
||||
// command safely (because there are other localhost certificates that were not created by asp.net core, it will throw.
|
||||
MakeCertificateKeyAccessibleAcrossPartitions(cert);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Diagnostics.Error("Failed to make certificate key accessible", ex);
|
||||
result.ResultCode = EnsureCertificateResult.FailedToMakeKeyAccessible;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
certificates = filteredCertificates;
|
||||
|
||||
result.ResultCode = EnsureCertificateResult.Succeeded;
|
||||
|
|
@ -794,6 +848,11 @@ namespace Microsoft.AspNetCore.Certificates.Generation
|
|||
result.ResultCode = EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && isInteractive)
|
||||
{
|
||||
MakeCertificateKeyAccessibleAcrossPartitions(certificate);
|
||||
}
|
||||
}
|
||||
if (path != null)
|
||||
{
|
||||
|
|
@ -835,6 +894,73 @@ namespace Microsoft.AspNetCore.Certificates.Generation
|
|||
return result;
|
||||
}
|
||||
|
||||
private void MakeCertificateKeyAccessibleAcrossPartitions(X509Certificate2 certificate) {
|
||||
if (OtherNonAspNetCoreHttpsCertificatesPresent())
|
||||
{
|
||||
throw new InvalidOperationException("Unable to make HTTPS ceritificate key trusted across security partitions.");
|
||||
}
|
||||
using (var process = Process.Start(MacOSSetPartitionKeyPermissionsCommandLine, MacOSSetPartitionKeyPermissionsCommandLineArguments))
|
||||
{
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException("Error making the key accessible across partitions.");
|
||||
}
|
||||
}
|
||||
|
||||
var certificateSentinelPath = GetCertificateSentinelPath(certificate);
|
||||
File.WriteAllText(certificateSentinelPath, "true");
|
||||
}
|
||||
|
||||
private static string GetCertificateSentinelPath(X509Certificate2 certificate) =>
|
||||
Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".dotnet", $"certificate.{certificate.GetCertHashString(HashAlgorithmName.SHA256)}.sentinel");
|
||||
|
||||
private bool OtherNonAspNetCoreHttpsCertificatesPresent()
|
||||
{
|
||||
var certificates = new List<X509Certificate2>();
|
||||
try
|
||||
{
|
||||
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
certificates.AddRange(store.Certificates.OfType<X509Certificate2>());
|
||||
IEnumerable<X509Certificate2> matchingCertificates = certificates;
|
||||
// Ensure the certificate hasn't expired, has a private key and its exportable
|
||||
// (for container/unix scenarios).
|
||||
var now = DateTimeOffset.Now;
|
||||
matchingCertificates = matchingCertificates
|
||||
.Where(c => c.NotBefore <= now &&
|
||||
now <= c.NotAfter && c.Subject == LocalhostHttpsDistinguishedName);
|
||||
|
||||
// We need to enumerate the certificates early to prevent dispoisng issues.
|
||||
matchingCertificates = matchingCertificates.ToList();
|
||||
|
||||
var certificatesToDispose = certificates.Except(matchingCertificates);
|
||||
DisposeCertificates(certificatesToDispose);
|
||||
|
||||
store.Close();
|
||||
|
||||
return matchingCertificates.All(c => !HasOid(c, AspNetHttpsOid));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
DisposeCertificates(certificates);
|
||||
certificates.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HasOid(X509Certificate2 certificate, string oid) =>
|
||||
certificate.Extensions.OfType<X509Extension>()
|
||||
.Any(e => string.Equals(oid, e.Oid.Value, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private bool CanAccessCertificateKeyAcrossPartitions(X509Certificate2 certificate)
|
||||
{
|
||||
var certificateSentinelPath = GetCertificateSentinelPath(certificate);
|
||||
return File.Exists(certificateSentinelPath);
|
||||
}
|
||||
|
||||
private class UserCancelledTrustException : Exception
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation
|
|||
ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore,
|
||||
ErrorExportingTheCertificate,
|
||||
FailedToTrustTheCertificate,
|
||||
UserCancelledTrustStep
|
||||
UserCancelledTrustStep,
|
||||
FailedToMakeKeyAccessible,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.XPlat
|
|||
{
|
||||
var manager = new CertificateManager();
|
||||
var now = DateTimeOffset.Now;
|
||||
manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
|
||||
manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), isInteractive: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
|
|||
// Act
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
|
||||
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, subject: TestCertificateSubject);
|
||||
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EnsureCertificateResult.Succeeded, result.ResultCode);
|
||||
|
|
@ -135,12 +135,12 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
|
|||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
var httpsCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject);
|
||||
|
||||
// Act
|
||||
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, subject: TestCertificateSubject);
|
||||
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result.ResultCode);
|
||||
|
|
@ -162,7 +162,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
|
|||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
CertificateManager.AspNetHttpsCertificateVersion = 2;
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
|
|||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
|
||||
CertificateManager.AspNetHttpsCertificateVersion = 0;
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
CertificateManager.AspNetHttpsCertificateVersion = 1;
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
|
|||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
|
||||
CertificateManager.AspNetHttpsCertificateVersion = 0;
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
|
||||
Assert.NotEmpty(httpsCertificateList);
|
||||
|
|
@ -211,7 +211,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
|
|||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
|
||||
CertificateManager.AspNetHttpsCertificateVersion = 2;
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
CertificateManager.AspNetHttpsCertificateVersion = 1;
|
||||
var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
|
||||
|
|
@ -225,7 +225,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
|
|||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
|
||||
var trustFailed = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject);
|
||||
var trustFailed = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
Assert.Equal(EnsureCertificateResult.UserCancelledTrustStep, trustFailed.ResultCode);
|
||||
}
|
||||
|
|
@ -237,7 +237,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests
|
|||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject);
|
||||
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject, isInteractive: false);
|
||||
|
||||
_manager.CleanupHttpsCertificates(TestCertificateSubject);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
|
|||
private const int ErrorNoValidCertificateFound = 6;
|
||||
private const int ErrorCertificateNotTrusted = 7;
|
||||
private const int ErrorCleaningUpCertificates = 8;
|
||||
private const int ErrorMacOsCertificateKeyCouldNotBeAccessible = 9;
|
||||
|
||||
public static readonly TimeSpan HttpsCertificateValidity = TimeSpan.FromDays(365);
|
||||
|
||||
|
|
@ -158,7 +159,15 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
|
|||
}
|
||||
else
|
||||
{
|
||||
reporter.Output("A valid certificate was found.");
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && certificateManager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions())
|
||||
{
|
||||
reporter.Warn($"A valid HTTPS certificate was found but it may not be accessible across security partitions. Run dotnet dev-certs https to ensure it will be accessible during development.");
|
||||
return ErrorMacOsCertificateKeyCouldNotBeAccessible;
|
||||
}
|
||||
else
|
||||
{
|
||||
reporter.Verbose("A valid certificate was found.");
|
||||
}
|
||||
}
|
||||
|
||||
if (trust != null && trust.HasValue())
|
||||
|
|
@ -185,6 +194,13 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
|
|||
var now = DateTimeOffset.Now;
|
||||
var manager = new CertificateManager();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && manager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions() || manager.GetHttpsCertificates().Count == 0)
|
||||
{
|
||||
reporter.Warn($"A valid HTTPS certificate with a key accessible across security partitions was not found. The following command will run to fix it:" + Environment.NewLine +
|
||||
"'sudo security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9'" + Environment.NewLine +
|
||||
"This command will make the certificate key accessible across security partitions and might prompt you for your password. For more information see: https://aka.ms/aspnetcore/3.1/troubleshootcertissues");
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && trust?.HasValue() == true)
|
||||
{
|
||||
reporter.Warn("Trusting the HTTPS development certificate was requested. If the certificate is not " +
|
||||
|
|
|
|||
Loading…
Reference in New Issue