@@ -41,6 +41,8 @@ internal class CertificateManager
41
41
private const string MacOSTrustCertificateCommandLine = "sudo" ;
42
42
private static readonly string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " " ;
43
43
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 ;
44
46
45
47
// Setting to 0 means we don't append the version byte,
46
48
// which is what all machines currently have.
@@ -177,6 +179,27 @@ private static void DisposeCertificates(IEnumerable<X509Certificate2> disposable
177
179
}
178
180
}
179
181
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 : false , requireExportable : true ) ;
202
+
180
203
public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate ( DateTimeOffset notBefore , DateTimeOffset notAfter , string subjectOverride , DiagnosticInformation diagnostics = null )
181
204
{
182
205
var subject = new X500DistinguishedName ( subjectOverride ?? LocalhostHttpsDistinguishedName ) ;
@@ -707,9 +730,10 @@ public DetailedEnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertifica
707
730
bool trust = false ,
708
731
bool includePrivateKey = false ,
709
732
string password = null ,
710
- string subject = LocalhostHttpsDistinguishedName )
733
+ string subject = LocalhostHttpsDistinguishedName ,
734
+ bool isInteractive = true )
711
735
{
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 ) ;
713
737
}
714
738
715
739
public DetailedEnsureCertificateResult EnsureValidCertificateExists (
@@ -720,7 +744,8 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
720
744
bool trust ,
721
745
bool includePrivateKey ,
722
746
string password ,
723
- string subject )
747
+ string subject ,
748
+ bool isInteractive )
724
749
{
725
750
if ( purpose == CertificatePurpose . All )
726
751
{
@@ -747,6 +772,35 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
747
772
result . Diagnostics . Debug ( "Skipped filtering certificates by subject." ) ;
748
773
}
749
774
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
+
750
804
certificates = filteredCertificates ;
751
805
752
806
result . ResultCode = EnsureCertificateResult . Succeeded ;
@@ -794,6 +848,11 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
794
848
result . ResultCode = EnsureCertificateResult . ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore ;
795
849
return result ;
796
850
}
851
+
852
+ if ( RuntimeInformation . IsOSPlatform ( OSPlatform . OSX ) && isInteractive )
853
+ {
854
+ MakeCertificateKeyAccessibleAcrossPartitions ( certificate ) ;
855
+ }
797
856
}
798
857
if ( path != null )
799
858
{
@@ -835,6 +894,73 @@ public DetailedEnsureCertificateResult EnsureValidCertificateExists(
835
894
return result ;
836
895
}
837
896
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
+
838
964
private class UserCancelledTrustException : Exception
839
965
{
840
966
}
0 commit comments