Home > database >  Replace SSL Certificate on Remote IIS 10 server from VB.NET
Replace SSL Certificate on Remote IIS 10 server from VB.NET

Time:03-09

I'm attempting to automate the process of renewing my SSL certificates for a few different publicly accessible endpoints. I'm using IIS 10 Site Bindings Dialog

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:

  1. Keep my existing code for adding the certificate to the store, then repair the certificate (as per naimadswdn's answer), or
  2. 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 the certutil 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.

  • Related