I'm attempting to automate the process of renewing my SSL certificates for a few different publicly accessible endpoints. I'm using
EDIT #3
After reading Conrado Clark's Developer Log entry titled Adding SSL Binding to a remote website using Microsoft.Web.Administration, I decided to try to add the NewBinding
to IIS instead of just updating the existing one:
Site.Bindings.Add(NewBinding)
Site.Bindings.Remove(SiteBinding)
HostManager.CommitChanges()
This produced a different exception: Cannot add duplicate collection entry of type 'binding' with combined key attributes 'protocol, bindingInformation' respectively set to 'https, XXX.XXX.XXX.XXX:443:'
So, I tried removing the existing binding first:
Site.Bindings.Remove(SiteBinding)
Site.Bindings.Add(NewBinding)
HostManager.CommitChanges()
This time it made it through the first two steps (Site.Bindings.Remove()
and Site.Bindings.Add()
), but when it tried to execute HostManager.CommitChanges()
, I got another "new" exception: A specified logon session does not exist. It may already have been terminated. (Exception from HRESULT: 0x80070520)
. Additionally, it "reset" the binding so there was no certificate installed on the site.
Just to see what would happen, I tried to commit the Site.Bindings.Remove()
before trying to add it back.
Site.Bindings.Remove(SiteBinding)
HostManager.CommitChanges()
Site.Bindings.Add(NewBinding)
HostManager.CommitChanges()
The initial commit seemed to work fine (and the binding disappeared completely from IIS), but when it went to add the new binding, I got this: The configuration object is read only, because it has been committed by a call to ServerManager.CommitChanges(). If write access is required, use ServerManager to get a new reference.
I manually recreated the binding (thankfully I had taken a quick screenshot before I started messing with it), but that last error has given me an idea for my next attempt. I'm going to try to break the Add()
and Remove()
methods out to new methods where I can open new instances of the ServerManager
object specifically for this purpose. I'll come back when I've had a chance to write/test that.
EDIT #4
I tried the above and still ended up with an error stating that A specified logon session does not exist. It may already have been terminated. (Exception from HRESULT: 0x80070520)
. So, just to see if I could determine the cause of the problem, I went to IIS and manually tried to apply the new certificate. I got the SAME EXACT ERROR from IIS! (Yes, I know... I probably should have checked that bit a long time ago, but here we are)
It looks like there's a problem with the certificate in the store. Digging around a little deeper, I found an old reference on the MSDN forums talking about this error being related to a missing private key. This makes it sound like I missed a step in the certificate installation process, so I guess I need to take another step back and figure out what's wrong with that method before proceeding.
CodePudding user response:
a
NotSupportedException
stating:The specified operation is not supported when a server name is specified
.
That's exactly what we should expect. Microsoft didn't develop that API to manage remote server's everything. As proof, you can see that even IIS Manager (built upon the same API) does not support managing server certificates of a remote machine.
If I were you, I will actually use other approaches, such as developing a dedicated small demon app to run on each IIS machines, so that actual communication via Microsoft.Web.Administration
happens locally, not remotely.
CodePudding user response:
I've actually gotten this working! As identified in my multiple EDITs to the OP, it seems the main problem actually had to do with the original import of the certificate. Once that issue was resolved, everything else pretty much fell into place. I've provided the full working code at the end if you want the TL;DR version, but here's what I found:
EXPLANATION/TROUBLESHOOTING
After a bunch of further research into the individual errors I encountered in my initial testing - specifically the A specified logon session does not exist. It may already have been terminated.
error - I ran across this SO question: IIS 7 Error "A specified logon session does not exist. It may already have been terminated." when using https.
In the linked answer from user naimadswdn, they state that:
I fixed it by running:
certutil.exe -repairstore $CertificateStoreName $CertThumbPrint
where
$CertificateStoreName
is store name, and$CertThumbPrint
is the thumbprint of imported certificate.
Another answer to that same question from user Ed Greaves provides a bit of explanation for the underlying cause of the problem:
This storage provider is a newer CNG provider and is not supported by IIS or .NET. You cannot access the key. Therefore you should use certutil.exe to install certificates in your scripts. Importing using the Certificate Manager MMC snap-in or IIS also works but for scripting, use certutil as follows:
The original question was asked about trying to import the certificate directly in IIS and, since my testing showed that I wasn't able to do that either, I went ahead and tried to repair the certificate from the command line on the server with certutil
. I went back to IIS and, this time was able to successfully apply the certificate to the binding without error.
Since that worked, I reset the binding and tried again to set it to the new certificate through my code. No exception was encountered and, when I went to check the binding in IIS, it showed the correct certificate selected. I verified through my browser that it was showing the new certificate and everything seems to be working as expected/intended.
Of course, I want this process automated, so I can't be logging in to the server to repair certificates every 90 days. So, now I have two options:
- Keep my existing code for adding the certificate to the store, then repair the certificate (as per naimadswdn's answer), or
- Take Ed Greaves' suggestion and use
certutil
to perform the actual import of the certificate.
Since I already have it importing the certificate into the store without any exception being thrown, I decided to go with the former solution (for now, at least). Of course, I want to do this remotely, so I've chosen to use WMI to execute certutil
on the server (see the CertUtilRepairCertificateInStore()
method in the full code listing below).
(FINAL) TESTING
I reset the binding on the IIS site and deleted the certificate from the store to test my "new" process:
- The certificate was successfully added to the store by the
AddCertificateToRemoteStore()
method, but I paused execution before allowing it to "repair" the certificate in the store. - While debugging was paused, I tried to manually apply the certificate to the binding in IIS. This resulted in the same
logon session does not exist
error. - I allowed the
CertUtilRepairCertificateInStore()
method to run thecertutil
on the server through WMI. I didn't get any exceptions. - I paused debugging again after the certificate was "repaired" and tried to manually apply the certifiate to the binding in IIS. Now the binding was successfully updated to use the new certificate.
- I manually reset the binding in IIS to use the old certificate and allowed my
ApplyCertificateBinding()
method to execute.
This time, the method completed without throwing any exceptions, so I went into IIS and verified that it does, indeed, have the new certificate applied to the appropriate binding. As one last bit of verification, I went to my browser and checked the certificate from the site itself and it shows the correct new Let's Encrypt certificate. It seems that, along with some other minor tweaks along the way, the certutil -repairstore
call was the final solution.
SOLUTION (CODE)
After all of that, I decided to keep my original code as-is and simply add the WMI bit to "repair" the certificate immediately after importing it to ensure it's ready to be applied to the binding. Yes, I could allow that to be handled in a Try/Catch
block for the binding, but I'd rather just avoid the issue altogether. Here's a (mostly) complete listing of the functional code I'm using now and, so far, it seems to work exactly as I require/expect.
Imports System.Security.Cryptography.X509Certificates
Imports System.Security.Permissions
Imports Microsoft.Web.Administration
Friend Async Function InstallSSLCertificate(ByVal PFXFile As IO.FileInfo) As Task(Of X509Certificate2)
If PFXFile Is Nothing Then
Throw New ArgumentNullException("PFXFile", "You must provide a valid PFX certificate file")
ElseIf Not PFXFile.Exists OrElse PFXFile.Length <= 0 Then
Throw New ArgumentException("PFXFile", "You must provide a valid PFX certificate file")
Else
Dim CertPFX As X509Certificate2 = Nothing
Dim CertSubject As String = String.Empty
'THE GetOperationalCredentials() METHOD (not defined here) IS A CUSTOM UTILITY METHOD FOR RETRIEVING A SPECIFIC SET OF CREDENTIALS STORED ELSEWHERE
Dim PFXCredentials As Net.NetworkCredential = GetOperationalCredentials(SecurityOperation.PFX)
Dim UserCredentials As Net.NetworkCredential = GetOperationalCredentials(SecurityOperation.Server)
If Not PFXCredentials Is Nothing Then
'BUILD/EXTRACT THE X509Certificate2 OBJECT INFORMATION FROM THE PFX FILE
'MAKE SURE TO SET THE X509KeyStorageFlags.MachineKeySet AND X509KeyStorageFlags.PersistKeySet FLAGS TO
' ENSURE THE CERTIFICATE PERSISTS IN THE STORE
CertPFX = New X509Certificate2(PFXFile.FullName, PFXCredentials.SecurePassword, X509KeyStorageFlags.MachineKeySet Or X509KeyStorageFlags.PersistKeySet)
CertSubject = CertPFX.GetNameInfo(X509NameType.DnsName, False)
If Not CertSubject Is Nothing AndAlso Not String.IsNullOrEmpty(CertSubject.Trim) Then
If CertSubject.ToLower.Trim.StartsWith("ftps") Then
'THIS CONDITIONAL (not defined here) IS FOR HANDLING CERTIFICATE(S) THAT CANNOT BE INSTALLED FROM A CERTIFICATE STORE
If Not Await InstallSSLCertificateFromPEM(CertSubject, PFXFile, UserCredentials) Then
Return Nothing
End If
Else
If Not InstallCertificateFromPFX(CertPFX, UserCredentials) Then
Return Nothing
End If
End If
Else
Return Nothing
End If
Else
Return Nothing
End If
Return CertPFX
End If
End Function
Private Function InstallCertificateFromPFX(ByVal Certificate As X509Certificate2, ByVal Credentials As Net.NetworkCredential) As Boolean
Dim Subject As String = Certificate.GetNameInfo(X509NameType.DnsName, False)
Dim Hostname As String = String.Empty
Dim StoreName As String = String.Empty
If Subject.ToLower.Trim.StartsWith("www") Then
Hostname = "<WEBSITE_SERVER_NAME>"
StoreName = "WebHosting"
ElseIf Subject.ToLower.Trim.StartsWith("rdp") Then
Hostname = "<REMOTE_DESKTOP_SERVER_NAME>"
StoreName = "My"
End If
Try
AddCertificateToRemoteStore(Hostname, StoreName, Certificate, Credentials)
ApplyCertificateBinding(Hostname, StoreName, Certificate)
'THE CleanUpCertifcateStore() METHOD (not defined here) IS SOMETHING I INTEND TO
' IMPLEMENT TO GET RID OF OLD UNUSED/EXPIRED CERTIFICATES
CleanUpCertifcateStore(Hostname, StoreName, Certificate)
Return True
Catch ex As Exception
MessageBox.Show(ex.Message)
Return False
End Try
End Function
''' <summary>
''' Connect to the remote certificate store and import the details from a valid <see cref="X509Certificate2"/> object
''' </summary>
''' <param name="HostName">The hostname of the server where the certificate store is located</param>
''' <param name="StoreName">The name of the certificate store into which the certificate should be imported</param>
''' <param name="Certificate">A valid <see cref="X509Certificate2"/> object containing the details of the certificate to be imported</param>
''' <param name="Credentials">A valid <see cref="Net.NetworkCredential"/> object for passing to the certificate repair method for establishing a WMI connection</param>
Private Sub AddCertificateToRemoteStore(ByVal HostName As String, ByVal StoreName As String, ByVal Certificate As X509Certificate2, ByVal Credentials As Net.NetworkCredential)
If HostName Is Nothing OrElse String.IsNullOrEmpty(HostName) Then
Throw New ArgumentNullException("HostName", "You must specify a server hostname")
ElseIf StoreName Is Nothing OrElse String.IsNullOrEmpty(StoreName) Then
Throw New ArgumentNullException("StoreName", "You must specify a certificate store name")
ElseIf Certificate Is Nothing Then
Throw New ArgumentNullException("Certificate", "A valid X509Certificate2 object is required")
Else
'SET UP THE PATHS TO THE APPROPRIATE CERTIFICATE STORES
Dim CertStorePath As String = String.Format("\\{0}\{1}", HostName, StoreName)
Dim RootStorePath As String = String.Format("\\{0}\Root", HostName)
Dim IntermediateStorePath As String = String.Format("\\{0}\CA", HostName)
'USE THE X509Chain OBJECT TO MAKE IT EASIER TO IDENTIFY THE APPROPRIATE STORE FOR
' EACH CERTIFICATE EXTRACTED FROM THE PFX
Using CertChain As New X509Chain
If CertChain.Build(Certificate) Then
Dim FindResults As X509Certificate2Collection
Dim StorePermissions As New StorePermission(PermissionState.Unrestricted)
With StorePermissions
.Flags = StorePermissionFlags.OpenStore Or StorePermissionFlags.AddToStore
.Assert()
End With
For C = 0 To CertChain.ChainElements.Count - 1
If C = 0 Then
'FIRST ELEMENT IN THE CHAIN = CERTIFICATE FOR THE SITE
Using CertificateStore As New X509Store(CertStorePath, StoreLocation.LocalMachine)
With CertificateStore
.Open(OpenFlags.ReadWrite Or OpenFlags.OpenExistingOnly)
FindResults = .Certificates.Find(X509FindType.FindByThumbprint, CertChain.ChainElements(C).Certificate.Thumbprint, False)
If FindResults.Count <= 0 Then
.Add(CertChain.ChainElements(C).Certificate)
End If
FindResults.Clear()
.Close()
End With
End Using
'REPAIR THE CERTIFICATE'S PROVIDER/PRIVATE KEY IN THE REMOTE STORE
CertUtilRepairCertificateInStore(HostName, StoreName, CertChain.ChainElements(C).Certificate, Credentials)
ElseIf C = CertChain.ChainElements.Count - 1 Then
'LAST ELEMENT IN THE CHAIN = ROOT CA CERTIFICATE
Using RootStore As New X509Store(RootStorePath, StoreLocation.LocalMachine)
With RootStore
.Open(OpenFlags.ReadWrite Or OpenFlags.OpenExistingOnly)
FindResults = .Certificates.Find(X509FindType.FindByThumbprint, CertChain.ChainElements(C).Certificate.Thumbprint, False)
If FindResults.Count <= 0 Then
.Add(CertChain.ChainElements(C).Certificate)
End If
FindResults.Clear()
.Close()
End With
End Using
Else
'ANY ELEMENT BETWEEN THE FIRST AND LAST IN THE CHAIN = INTERMEDIATE CA CERTIFICATE(S)
Using IntermediateStore As New X509Store(IntermediateStorePath, StoreLocation.LocalMachine)
With IntermediateStore
.Open(OpenFlags.ReadWrite Or OpenFlags.OpenExistingOnly)
FindResults = .Certificates.Find(X509FindType.FindByThumbprint, CertChain.ChainElements(C).Certificate.Thumbprint, False)
If FindResults.Count <= 0 Then
.Add(CertChain.ChainElements(C).Certificate)
End If
FindResults.Clear()
.Close()
End With
End Using
End If
Next C
End If
End Using
End If
End Sub
''' <summary>
''' Use WMI to execute certutil.exe on the remote server to "repair" the certificate and correct issues with the provider/private key
''' </summary>
''' <param name="HostName">The hostname of the server where the certificate store is located</param>
''' <param name="StoreName">The name of the certificate store into which the certificate has been imported</param>
''' <param name="ActiveCertificate">A valid <see cref="X509Certificate2"/> object containing the details of the certificate to be repaired</param>
''' <param name="Credentials">A valid <see cref="Net.NetworkCredential"/> object for establishing the WMI connection</param>
Private Sub CertUtilRepairCertificateInStore(ByVal HostName As String, ByVal StoreName As String, ByVal ActiveCertificate As X509Certificate2, ByVal Credentials As Net.NetworkCredential)
Dim WMIOptions As New Management.ConnectionOptions
Dim WMIScope As Management.ManagementScope
Dim GetOptions As New Management.ObjectGetOptions
Dim WMIProcess As Management.ManagementClass
Dim WMIParameters As Management.ManagementBaseObject
With WMIOptions
.Username = Credentials.Domain & "\" & Credentials.UserName
.Password = Credentials.Password
.Impersonation = Management.ImpersonationLevel.Impersonate
.Authentication = Management.AuthenticationLevel.PacketPrivacy
End With
WMIScope = New Management.ManagementScope(String.Format("\\{0}\root\cimv2", HostName), WMIOptions)
WMIScope.Connect()
WMIProcess = New Management.ManagementClass(WMIScope, New Management.ManagementPath("root\cimv2:Win32_Process"), GetOptions)
WMIParameters = WMIProcess.GetMethodParameters("Create")
WMIParameters("CommandLine") = String.Format("cmd.exe /c C:\Windows\System32\certutil.exe -repairstore {0} {1}", StoreName, ActiveCertificate.Thumbprint)
WMIProcess.InvokeMethod("Create", WMIParameters, Nothing)
End Sub
''' <summary>
''' Connect to IIS on a remote host to apply a new certificate to a site's SSL bindings
''' </summary>
''' <param name="HostName">The hostname of the server where the certificate store is located</param>
''' <param name="StoreName">The name of the certificate store into which the certificate has been imported</param>
''' <param name="ActiveCertificate">A valid <see cref="X509Certificate2"/> object containing the details of the certificate that has been imported</param>
Private Sub ApplyCertificateBinding(ByVal HostName As String, ByVal StoreName As String, ByVal ActiveCertificate As X509Certificate2)
If HostName Is Nothing OrElse String.IsNullOrEmpty(HostName) Then
Throw New ArgumentNullException("HostName", "You must specify a server hostname")
ElseIf StoreName Is Nothing OrElse String.IsNullOrEmpty(StoreName) Then
Throw New ArgumentNullException("StoreName", "You must specify a certificate store name")
ElseIf ActiveCertificate Is Nothing Then
Throw New ArgumentNullException("ActiveCertificate", "A valid X509Certificate2 object is required")
Else
Dim SSLSiteName As String = ActiveCertificate.GetNameInfo(X509NameType.DnsName, False)
Dim HostSites As New List(Of Site)
Using HostManager As ServerManager = ServerManager.OpenRemote(HostName)
'FIND THE SITE(S) IN IIS THAT MATCH(ES) THE DETAILS FROM THE SSL CERTIFICATE
'>>> THIS IS **FAR FROM** BULLET-PROOF, BUT I AM NOT SURE HOW TO MAKE IT BETTER <<<
For Each Site As Site In HostManager.Sites
If Site.Name = SSLSiteName Then
HostSites.Add(Site)
Else
For Each Binding In Site.Bindings
If Binding.Host = SSLSiteName Then
HostSites.Add(Site)
Exit For
End If
Next Binding
End If
Next Site
For Each Site As Site In HostSites
For Each SiteBinding In Site.Bindings.ToList
If SiteBinding.Protocol = "https" Then
If Not SiteBinding.CertificateHash.SequenceEqual(ActiveCertificate.GetCertHash) Then
'CANNOT JUST EDIT OR "REPLACE" AN EXISTING BINDING ON A REMOTE IIS HOST SO
' CREATE A NEW BINDING TO ADD TO THE SITE AFTER THE EXISTING BINDING HAS
' BEEN REMOVED
Dim NewBinding As Binding = Site.Bindings.CreateElement
NewBinding.CertificateStoreName = StoreName
NewBinding.Protocol = "https"
NewBinding.CertificateHash = ActiveCertificate.GetCertHash
NewBinding.BindingInformation = SiteBinding.BindingInformation
'THIS PROCESS MUST BE COMPLETED IN THIS ORDER
Site.Bindings.Remove(SiteBinding)
Site.Bindings.Add(NewBinding)
HostManager.CommitChanges()
End If
End If
Next SiteBinding
'RESTARTING THE SITE IN IIS (there iss almost certainly a better way to do this)
Site.Stop()
Do While Site.State <> ObjectState.Stopped
Loop
Site.Start()
Do While Site.State <> ObjectState.Started
Loop
Next Site
End Using
End If
End Sub
I'm stepping into all of that from a method that looks for "pending" .pfx
files that are waiting to be processd:
Private Async Sub CheckForNewSSLCertificates()
Dim PendingSSLFolder As DirectoryInfo = New DirectoryInfo("\\SERVER\Certify\PendingSSL\")
For Each PFXFile As FileInfo In PendingSSLFolder.GetFiles("*.pfx")
Dim CertPFX As Security.Cryptography.X509Certificates.X509Certificate2 = Await InstallSSLCertificate(PFXFile)
If Not CertPFX Is Nothing Then
'[ARCHIVE THE PFX]
End If
Next PFXFile
End Sub
I know the documentation is a bit sparse but, if you find ways to make this more effective/efficient, or if you have any questions about what it's doing here, please feel free to let me know. Eventually I may try to just use certutil
to import the PFX and one-shot that process, but for now I just wanted to leave this here for anyone else who's trying to implement some "centralized" automation for SSL certificate management.