Skip to content

Commit bba3430

Browse files
javiercnmkArtakMSFT
authored andcommitted
[Platform] Detect and fix certificates with potentially inaccessible keys on Mac OS (3.0) (#17580)
* [Platform] Provide a better error message when the developer certificate can't be used (#16659) Improves the error message Kestrel gives when the developer certificate key is not available for some reason. * [Platform] Add logic to dotnet-dev-certs to detect and fix certificates with inaccessible keys on Mac OS * Update the docs link
1 parent d047a30 commit bba3430

File tree

9 files changed

+260
-38
lines changed

9 files changed

+260
-38
lines changed

src/Servers/Kestrel/Core/src/CoreStrings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,4 +617,7 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l
617617
<data name="Http2TellClientToCalmDown" xml:space="preserve">
618618
<value>A new stream was refused because this connection has too many streams that haven't finished processing. This may happen if many streams are aborted but not yet cleaned up.</value>
619619
</data>
620+
<data name="BadDeveloperCertificateState" xml:space="preserve">
621+
<value>The ASP.NET Core developer certificate is in an invalid state. To fix this issue, run the following commands 'dotnet dev-certs https --clean' and 'dotnet dev-certs https' to remove all existing ASP.NET Core development certificates and create a new untrusted developer certificate. On macOS or Windows, use 'dotnet dev-certs https --trust' to trust the new certificate.</value>
622+
</data>
620623
</root>

src/Servers/Kestrel/Core/src/KestrelServerOptions.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,7 @@ private void EnsureDefaultCert()
142142
var logger = ApplicationServices.GetRequiredService<ILogger<KestrelServer>>();
143143
try
144144
{
145-
var certificateManager = new CertificateManager();
146-
DefaultCertificate = certificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true)
145+
DefaultCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true)
147146
.FirstOrDefault();
148147

149148
if (DefaultCertificate != null)

src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Security.Cryptography.X509Certificates;
1212
using System.Threading;
1313
using System.Threading.Tasks;
14+
using Microsoft.AspNetCore.Certificates.Generation;
1415
using Microsoft.AspNetCore.Connections;
1516
using Microsoft.AspNetCore.Connections.Features;
1617
using Microsoft.AspNetCore.Http.Features;
@@ -208,12 +209,28 @@ private async Task InnerOnConnectionAsync(ConnectionContext context)
208209
await sslStream.DisposeAsync();
209210
return;
210211
}
211-
catch (Exception ex) when (ex is IOException || ex is AuthenticationException)
212+
catch (IOException ex)
212213
{
213214
_logger?.LogDebug(1, ex, CoreStrings.AuthenticationFailed);
214215
await sslStream.DisposeAsync();
215216
return;
216217
}
218+
catch (AuthenticationException ex)
219+
{
220+
if (_serverCertificate == null ||
221+
!CertificateManager.IsHttpsDevelopmentCertificate(_serverCertificate) ||
222+
CertificateManager.CheckDeveloperCertificateKey(_serverCertificate))
223+
{
224+
_logger?.LogDebug(1, ex, CoreStrings.AuthenticationFailed);
225+
}
226+
else
227+
{
228+
_logger?.LogError(2, ex, CoreStrings.BadDeveloperCertificateState);
229+
}
230+
231+
await sslStream.DisposeAsync();
232+
return;
233+
}
217234
}
218235

219236
feature.ApplicationProtocol = sslStream.NegotiatedApplicationProtocol.Protocol;

src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ public async Task ClientAttemptingToUseUnsupportedProtocolIsLoggedAsDebug()
364364
new TestServiceContext(LoggerFactory),
365365
listenOptions =>
366366
{
367-
listenOptions.UseHttps(TestResources.GetTestCertificate());
367+
listenOptions.UseHttps(TestResources.GetTestCertificate("no_extensions.pfx"));
368368
}))
369369
{
370370
using (var connection = server.CreateConnection())
@@ -383,6 +383,35 @@ await Assert.ThrowsAsync<IOException>(() =>
383383
Assert.Equal(LogLevel.Debug, loggerProvider.FilterLogger.LastLogLevel);
384384
}
385385

386+
[Fact]
387+
public async Task DevCertWithInvalidPrivateKeyProducesCustomWarning()
388+
{
389+
var loggerProvider = new HandshakeErrorLoggerProvider();
390+
LoggerFactory.AddProvider(loggerProvider);
391+
392+
await using (var server = new TestServer(context => Task.CompletedTask,
393+
new TestServiceContext(LoggerFactory),
394+
listenOptions =>
395+
{
396+
listenOptions.UseHttps(TestResources.GetTestCertificate());
397+
}))
398+
{
399+
using (var connection = server.CreateConnection())
400+
using (var sslStream = new SslStream(connection.Stream, true, (sender, certificate, chain, errors) => true))
401+
{
402+
// SslProtocols.Tls is TLS 1.0 which isn't supported by Kestrel by default.
403+
await Assert.ThrowsAsync<IOException>(() =>
404+
sslStream.AuthenticateAsClientAsync("127.0.0.1", clientCertificates: null,
405+
enabledSslProtocols: SslProtocols.Tls,
406+
checkCertificateRevocation: false));
407+
}
408+
}
409+
410+
await loggerProvider.FilterLogger.LogTcs.Task.DefaultTimeout();
411+
Assert.Equal(2, loggerProvider.FilterLogger.LastEventId);
412+
Assert.Equal(LogLevel.Error, loggerProvider.FilterLogger.LastLogLevel);
413+
}
414+
386415
[Fact]
387416
public async Task OnAuthenticate_SeesOtherSettings()
388417
{

src/Shared/CertificateGeneration/CertificateManager.cs

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,18 @@ 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.
47-
public int AspNetHttpsCertificateVersion { get; set; } = 1;
49+
public static int AspNetHttpsCertificateVersion { get; set; } = 1;
4850

49-
public IList<X509Certificate2> ListCertificates(
51+
public static bool IsHttpsDevelopmentCertificate(X509Certificate2 certificate) =>
52+
certificate.Extensions.OfType<X509Extension>()
53+
.Any(e => string.Equals(AspNetHttpsOid, e.Oid.Value, StringComparison.Ordinal));
54+
55+
public static IList<X509Certificate2> ListCertificates(
5056
CertificatePurpose purpose,
5157
StoreName storeName,
5258
StoreLocation location,
@@ -173,6 +179,27 @@ private static void DisposeCertificates(IEnumerable<X509Certificate2> disposable
173179
}
174180
}
175181

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+
176203
public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter, string subjectOverride, DiagnosticInformation diagnostics = null)
177204
{
178205
var subject = new X500DistinguishedName(subjectOverride ?? LocalhostHttpsDistinguishedName);
@@ -228,6 +255,33 @@ public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffs
228255
return certificate;
229256
}
230257

258+
internal static bool CheckDeveloperCertificateKey(X509Certificate2 candidate)
259+
{
260+
// Tries to use the certificate key to validate it can't access it
261+
try
262+
{
263+
var rsa = candidate.GetRSAPrivateKey();
264+
if (rsa == null)
265+
{
266+
return false;
267+
}
268+
269+
// Encrypting a random value is the ultimate test for a key validity.
270+
// Windows and Mac OS both return HasPrivateKey = true if there is (or there has been) a private key associated
271+
// with the certificate at some point.
272+
var value = new byte[32];
273+
RandomNumberGenerator.Fill(value);
274+
rsa.Decrypt(rsa.Encrypt(value, RSAEncryptionPadding.Pkcs1), RSAEncryptionPadding.Pkcs1);
275+
276+
// Being able to encrypt and decrypt a payload is the strongest guarantee that the key is valid.
277+
return true;
278+
}
279+
catch (Exception)
280+
{
281+
return false;
282+
}
283+
}
284+
231285
public X509Certificate2 CreateSelfSignedCertificate(
232286
X500DistinguishedName subject,
233287
IEnumerable<X509Extension> extensions,
@@ -676,9 +730,10 @@ public DetailedEnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertifica
676730
bool trust = false,
677731
bool includePrivateKey = false,
678732
string password = null,
679-
string subject = LocalhostHttpsDistinguishedName)
733+
string subject = LocalhostHttpsDistinguishedName,
734+
bool isInteractive = true)
680735
{
681-
return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject);
736+
return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject, isInteractive);
682737
}
683738

684739
public DetailedEnsureCertificateResult EnsureValidCertificateExists(
@@ -689,7 +744,8 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
689744
bool trust,
690745
bool includePrivateKey,
691746
string password,
692-
string subject)
747+
string subject,
748+
bool isInteractive)
693749
{
694750
if (purpose == CertificatePurpose.All)
695751
{
@@ -716,6 +772,35 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
716772
result.Diagnostics.Debug("Skipped filtering certificates by subject.");
717773
}
718774

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+
719804
certificates = filteredCertificates;
720805

721806
result.ResultCode = EnsureCertificateResult.Succeeded;
@@ -763,6 +848,11 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
763848
result.ResultCode = EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore;
764849
return result;
765850
}
851+
852+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && isInteractive)
853+
{
854+
MakeCertificateKeyAccessibleAcrossPartitions(certificate);
855+
}
766856
}
767857
if (path != null)
768858
{
@@ -804,6 +894,73 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
804894
return result;
805895
}
806896

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+
807964
private class UserCancelledTrustException : Exception
808965
{
809966
}

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
}

0 commit comments

Comments
 (0)