[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:
Javier Calvarro Nelson 2020-01-16 10:29:37 -08:00 committed by Artak
parent 4682c2a6dc
commit 8e5767bdf1
5 changed files with 158 additions and 15 deletions

View File

@ -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
{
}

View File

@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation
ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore,
ErrorExportingTheCertificate,
FailedToTrustTheCertificate,
UserCancelledTrustStep
UserCancelledTrustStep,
FailedToMakeKeyAccessible,
}
}

View File

@ -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);
}
}
}

View File

@ -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);

View File

@ -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 " +