Skip to content

Commit 36bdd51

Browse files
committed
[Platform] Add logic to dotnet-dev-certs to detect and fix certificates with inaccessible keys on Mac OS
1 parent 9837650 commit 36bdd51

File tree

5 files changed

+158
-15
lines changed

5 files changed

+158
-15
lines changed

src/Shared/CertificateGeneration/CertificateManager.cs

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ internal class CertificateManager
4141
private const string MacOSTrustCertificateCommandLine = "sudo";
4242
private static readonly string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " ";
4343
private const int UserCancelledErrorCode = 1223;
44+
private const string MacOSSetPartitionKeyPermissionsCommandLine = "sudo";
45+
private static readonly string MacOSSetPartitionKeyPermissionsCommandLineArguments = "security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9 " + MacOSUserKeyChain;
4446

4547
// Setting to 0 means we don't append the version byte,
4648
// which is what all machines currently have.
@@ -177,6 +179,27 @@ private static void DisposeCertificates(IEnumerable<X509Certificate2> disposable
177179
}
178180
}
179181

182+
internal bool HasValidCertificateWithInnaccessibleKeyAcrossPartitions()
183+
{
184+
var certificates = GetHttpsCertificates();
185+
if (certificates.Count == 0)
186+
{
187+
return false;
188+
}
189+
190+
// We need to check all certificates as a new one might be created that hasn't been correctly setup.
191+
var result = false;
192+
foreach (var certificate in certificates)
193+
{
194+
result = result || !CanAccessCertificateKeyAcrossPartitions(certificate);
195+
}
196+
197+
return result;
198+
}
199+
200+
public IList<X509Certificate2> GetHttpsCertificates() =>
201+
ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true);
202+
180203
public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter, string subjectOverride, DiagnosticInformation diagnostics = null)
181204
{
182205
var subject = new X500DistinguishedName(subjectOverride ?? LocalhostHttpsDistinguishedName);
@@ -707,9 +730,10 @@ public DetailedEnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertifica
707730
bool trust = false,
708731
bool includePrivateKey = false,
709732
string password = null,
710-
string subject = LocalhostHttpsDistinguishedName)
733+
string subject = LocalhostHttpsDistinguishedName,
734+
bool isInteractive = true)
711735
{
712-
return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject);
736+
return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject, isInteractive);
713737
}
714738

715739
public DetailedEnsureCertificateResult EnsureValidCertificateExists(
@@ -720,7 +744,8 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
720744
bool trust,
721745
bool includePrivateKey,
722746
string password,
723-
string subject)
747+
string subject,
748+
bool isInteractive)
724749
{
725750
if (purpose == CertificatePurpose.All)
726751
{
@@ -747,6 +772,35 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
747772
result.Diagnostics.Debug("Skipped filtering certificates by subject.");
748773
}
749774

775+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
776+
{
777+
foreach (var cert in filteredCertificates)
778+
{
779+
if (!CanAccessCertificateKeyAcrossPartitions(cert))
780+
{
781+
if (!isInteractive)
782+
{
783+
// If the process is not interactive (first run experience) bail out. We will simply create a certificate
784+
// in case there is none or report success during the first run experience.
785+
break;
786+
}
787+
try
788+
{
789+
// The command we run handles making keys for all localhost certificates accessible across partitions. If it can not run the
790+
// command safely (because there are other localhost certificates that were not created by asp.net core, it will throw.
791+
MakeCertificateKeyAccessibleAcrossPartitions(cert);
792+
break;
793+
}
794+
catch (Exception ex)
795+
{
796+
result.Diagnostics.Error("Failed to make certificate key accessible", ex);
797+
result.ResultCode = EnsureCertificateResult.FailedToMakeKeyAccessible;
798+
return result;
799+
}
800+
}
801+
}
802+
}
803+
750804
certificates = filteredCertificates;
751805

752806
result.ResultCode = EnsureCertificateResult.Succeeded;
@@ -794,6 +848,11 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
794848
result.ResultCode = EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore;
795849
return result;
796850
}
851+
852+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && isInteractive)
853+
{
854+
MakeCertificateKeyAccessibleAcrossPartitions(certificate);
855+
}
797856
}
798857
if (path != null)
799858
{
@@ -835,6 +894,73 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
835894
return result;
836895
}
837896

897+
private void MakeCertificateKeyAccessibleAcrossPartitions(X509Certificate2 certificate) {
898+
if (OtherNonAspNetCoreHttpsCertificatesPresent())
899+
{
900+
throw new InvalidOperationException("Unable to make HTTPS ceritificate key trusted across security partitions.");
901+
}
902+
using (var process = Process.Start(MacOSSetPartitionKeyPermissionsCommandLine, MacOSSetPartitionKeyPermissionsCommandLineArguments))
903+
{
904+
process.WaitForExit();
905+
if (process.ExitCode != 0)
906+
{
907+
throw new InvalidOperationException("Error making the key accessible across partitions.");
908+
}
909+
}
910+
911+
var certificateSentinelPath = GetCertificateSentinelPath(certificate);
912+
File.WriteAllText(certificateSentinelPath, "true");
913+
}
914+
915+
private static string GetCertificateSentinelPath(X509Certificate2 certificate) =>
916+
Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".dotnet", $"certificate.{certificate.GetCertHashString(HashAlgorithmName.SHA256)}.sentinel");
917+
918+
private bool OtherNonAspNetCoreHttpsCertificatesPresent()
919+
{
920+
var certificates = new List<X509Certificate2>();
921+
try
922+
{
923+
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
924+
{
925+
store.Open(OpenFlags.ReadOnly);
926+
certificates.AddRange(store.Certificates.OfType<X509Certificate2>());
927+
IEnumerable<X509Certificate2> matchingCertificates = certificates;
928+
// Ensure the certificate hasn't expired, has a private key and its exportable
929+
// (for container/unix scenarios).
930+
var now = DateTimeOffset.Now;
931+
matchingCertificates = matchingCertificates
932+
.Where(c => c.NotBefore <= now &&
933+
now <= c.NotAfter && c.Subject == LocalhostHttpsDistinguishedName);
934+
935+
// We need to enumerate the certificates early to prevent dispoisng issues.
936+
matchingCertificates = matchingCertificates.ToList();
937+
938+
var certificatesToDispose = certificates.Except(matchingCertificates);
939+
DisposeCertificates(certificatesToDispose);
940+
941+
store.Close();
942+
943+
return matchingCertificates.All(c => !HasOid(c, AspNetHttpsOid));
944+
}
945+
}
946+
catch
947+
{
948+
DisposeCertificates(certificates);
949+
certificates.Clear();
950+
return true;
951+
}
952+
953+
bool HasOid(X509Certificate2 certificate, string oid) =>
954+
certificate.Extensions.OfType<X509Extension>()
955+
.Any(e => string.Equals(oid, e.Oid.Value, StringComparison.Ordinal));
956+
}
957+
958+
private bool CanAccessCertificateKeyAcrossPartitions(X509Certificate2 certificate)
959+
{
960+
var certificateSentinelPath = GetCertificateSentinelPath(certificate);
961+
return File.Exists(certificateSentinelPath);
962+
}
963+
838964
private class UserCancelledTrustException : Exception
839965
{
840966
}

src/Shared/CertificateGeneration/EnsureCertificateResult.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal enum EnsureCertificateResult
1111
ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore,
1212
ErrorExportingTheCertificate,
1313
FailedToTrustTheCertificate,
14-
UserCancelledTrustStep
14+
UserCancelledTrustStep,
15+
FailedToMakeKeyAccessible,
1516
}
1617
}

src/Tools/FirstRunCertGenerator/src/CertificateGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ public static void GenerateAspNetHttpsCertificate()
99
{
1010
var manager = new CertificateManager();
1111
var now = DateTimeOffset.Now;
12-
manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
12+
manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), isInteractive: false);
1313
}
1414
}
1515
}

src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public void EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttps
4242
// Act
4343
DateTimeOffset now = DateTimeOffset.UtcNow;
4444
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
45-
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, subject: TestCertificateSubject);
45+
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, subject: TestCertificateSubject, isInteractive: false);
4646

4747
// Assert
4848
Assert.Equal(EnsureCertificateResult.Succeeded, result.ResultCode);
@@ -135,12 +135,12 @@ public void EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAn
135135

136136
DateTimeOffset now = DateTimeOffset.UtcNow;
137137
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
138-
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
138+
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
139139

140140
var httpsCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject);
141141

142142
// Act
143-
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, subject: TestCertificateSubject);
143+
var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, subject: TestCertificateSubject, isInteractive: false);
144144

145145
// Assert
146146
Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result.ResultCode);
@@ -162,7 +162,7 @@ public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateIfVersionIsInc
162162

163163
DateTimeOffset now = DateTimeOffset.UtcNow;
164164
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
165-
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
165+
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
166166

167167
CertificateManager.AspNetHttpsCertificateVersion = 2;
168168

@@ -179,7 +179,7 @@ public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateForEmptyVersio
179179
DateTimeOffset now = DateTimeOffset.UtcNow;
180180
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
181181
CertificateManager.AspNetHttpsCertificateVersion = 0;
182-
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
182+
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
183183

184184
CertificateManager.AspNetHttpsCertificateVersion = 1;
185185

@@ -196,7 +196,7 @@ public void EnsureCreateHttpsCertificate_ReturnsValidIfVersionIsZero()
196196
DateTimeOffset now = DateTimeOffset.UtcNow;
197197
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
198198
CertificateManager.AspNetHttpsCertificateVersion = 0;
199-
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
199+
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
200200

201201
var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
202202
Assert.NotEmpty(httpsCertificateList);
@@ -211,7 +211,7 @@ public void EnsureCreateHttpsCertificate_ReturnValidIfCertIsNewer()
211211
DateTimeOffset now = DateTimeOffset.UtcNow;
212212
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
213213
CertificateManager.AspNetHttpsCertificateVersion = 2;
214-
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject);
214+
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false);
215215

216216
CertificateManager.AspNetHttpsCertificateVersion = 1;
217217
var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true);
@@ -225,7 +225,7 @@ public void EnsureAspNetCoreHttpsDevelopmentCertificate_ReturnsCorrectResult_Whe
225225

226226
DateTimeOffset now = DateTimeOffset.UtcNow;
227227
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
228-
var trustFailed = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject);
228+
var trustFailed = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject, isInteractive: false);
229229

230230
Assert.Equal(EnsureCertificateResult.UserCancelledTrustStep, trustFailed.ResultCode);
231231
}
@@ -237,7 +237,7 @@ public void EnsureAspNetCoreHttpsDevelopmentCertificate_CanRemoveCertificates()
237237

238238
DateTimeOffset now = DateTimeOffset.UtcNow;
239239
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
240-
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject);
240+
_manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject, isInteractive: false);
241241

242242
_manager.CleanupHttpsCertificates(TestCertificateSubject);
243243

src/Tools/dotnet-dev-certs/src/Program.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ internal class Program
2424
private const int ErrorNoValidCertificateFound = 6;
2525
private const int ErrorCertificateNotTrusted = 7;
2626
private const int ErrorCleaningUpCertificates = 8;
27+
private const int ErrorMacOsCertificateKeyCouldNotBeAccessible = 9;
2728

2829
public static readonly TimeSpan HttpsCertificateValidity = TimeSpan.FromDays(365);
2930

@@ -158,7 +159,15 @@ private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter
158159
}
159160
else
160161
{
161-
reporter.Output("A valid certificate was found.");
162+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && certificateManager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions())
163+
{
164+
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.");
165+
return ErrorMacOsCertificateKeyCouldNotBeAccessible;
166+
}
167+
else
168+
{
169+
reporter.Verbose("A valid certificate was found.");
170+
}
162171
}
163172

164173
if (trust != null && trust.HasValue())
@@ -185,6 +194,13 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio
185194
var now = DateTimeOffset.Now;
186195
var manager = new CertificateManager();
187196

197+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && manager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions() || manager.GetHttpsCertificates().Count == 0)
198+
{
199+
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 +
200+
"'sudo security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9'" + Environment.NewLine +
201+
"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/2.1/troubleshootcertissues");
202+
}
203+
188204
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && trust?.HasValue() == true)
189205
{
190206
reporter.Warn("Trusting the HTTPS development certificate was requested. If the certificate is not " +

0 commit comments

Comments
 (0)