About the problem

PowerShell MVP, Adam Bertram with his recent tweet inspired me to write this blog post. I realized that I see a lot of articles which describe how to delete certificate from certificate store, but never saw article that would describe how to do it properly.

Let’s recall what is wrong here. Years ago I wrote a blog post: The case of accidentally deleted user certificates that describes steps how to restore deleted certificate with private key by having a backup certificate only with public key. I explained why you can use certificate with just public key to restore bindings to private key. The answer was: when you delete certificate by using standard means (certificate store management functions in CryptoAPI), the private key is not deleted! Standard tools includes: Certificates MMC snap-in, X509Store class in .NET, certutil -delstore, etc., all they use Certificate and Certificate Store Functions. Our goal now is to fill the gap and provide an ability to remove private key along with certificate when you work in PowerShell.

Solution guide

The solution consist of some work with unmanaged CryptoAPI functions.

Deleting legacy CSP key

Main function is: CryptAcquireContext. This function accepts four parameters:

  1. pszContainer – is the key container name. This identifies the key within a particular cryptography service provider.
  2. pszProvider – is the provider name that stores our private key.
  3. dwProvType – specifies the provider type.
  4. dwFlags – configures the function to perform specific operations. For your specific scenario, a CRYPT_DELETEKEYSET flag is used.

Where we can get values for first three parameters? For certificates that use legacy CSP to store private keys, this information can be retrieved directly from X509Certificate2 object:

[↓] [vPodans] (dir cert:\CurrentUser\My)[0].privatekey.cspkeycontainerinfo


MachineKeyStore        : False
ProviderName           : Microsoft Enhanced Cryptographic Provider v1.0
ProviderType           : 1
KeyContainerName       : efc3deed-fc95-44a6-8e04-ac480ab0fed3
UniqueKeyContainerName : 1d5c79da24200a19f42fa77a50cb5aa3_3ac92f0d-f753-422d-9fa3-f769bd32b511
KeyNumber              : Exchange
Exportable             : True
HardwareDevice         : False
Removable              : False
Accessible             : True
Protected              : False
CryptoKeySecurity      :
RandomlyGenerated      : False



[↓] [vPodans]

I selected values in the output that will fill the function parameters. So far, so good.

Deleting CNG key

However, we have to support CNG (that uses modern CAPI2 key storage providers instead of legacy CSP) and quickly run into the problem:

PS C:\> (dir Cert:\LocalMachine\My)[0] | fl *priv*


HasPrivateKey : True
PrivateKey    :



PS C:\>

The HasPrivateKey property is set to True, but PrivateKey property is NULL, as the result, we cannot access private key object in .NET. In fact, it is a known limitation of .NET which very poorly support CNG. Fortunately, there is a way to get this information. Some time ago, I posted a great article about accessing CNG private key information: Retrieve CNG key container name and unique name. We can reuse that article, by calling NCryptOpenStorageProvider and NCryptOpenKey and then call NCryptDeleteKey function. However, there is a shortcut: we can call CryptAcquireCertificatePrivateKey function with CRYPT_ACQUIRE_ONLY_NCRYPT_KEY_FLAG value in the dwFlags parameter and pass resulted key handle (in the phCryptProvOrNCryptKey parameter) to NCryptDeleteKey function.

Final code

Now we are ready to write the final code. The code will be implemented as a helper function which you can embed in your certificate deletion scripts.

function Remove-PrivateKey {
[CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Security.Cryptography.X509Certificates.X509Certificate2[]]$Certificate
    )
    begin {
    # define unmanaged functions to retrieve private key information and delete the key.
$signature = @"
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern bool CryptAcquireContext(
   ref IntPtr phProv,
   string pszContainer,
   string pszProvider,
   uint dwProvType,
   long dwFlags
);
[DllImport("Crypt32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern bool CryptAcquireCertificatePrivateKey(
    IntPtr pCert,
    uint dwFlags,
    IntPtr pvReserved,
    ref IntPtr phCryptProv,
    ref uint pdwKeySpec,
    ref bool pfCallerFreeProv
);
[DllImport("NCrypt.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern int NCryptDeleteKey(
   IntPtr hKey,
   uint dwFlags
);
[DllImport("Crypt32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern bool CertGetCertificateContextProperty(
    IntPtr pCertContext,
    uint dwPropId,
    IntPtr pvData,
    ref uint pcbData
);
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
public struct CRYPT_KEY_PROV_INFO {
    [MarshalAs(UnmanagedType.LPWStr)]
    public string pwszContainerName;
    [MarshalAs(UnmanagedType.LPWStr)]
    public string pwszProvName;
    public uint dwProvType;
    public uint dwFlags;
    public uint cProvParam;
    public IntPtr rgProvParam;
    public uint dwKeySpec;
}
"@
        Add-Type -MemberDefinition $signature -Namespace PKI -Name PfxTools
        $CERT_KEY_PROV_INFO_PROP_ID = 0x2
        $CRYPT_DELETEKEYSET = 0x10
        $CRYPT_ACQUIRE_ONLY_NCRYPT_KEY_FLAG = 0x40000
    }
    process {
        foreach ($cert in $Certificate) {
            # if the current certificate do not have private key -- skip
            if (!$cert.HasPrivateKey) {continue}
            # if we reach this far and PrivateKey is $null -- the key is CNG key.
            if ($cert.PrivateKey -eq $null) {
                $phCryptProv = [IntPtr]::Zero
                $dwFlags = $CRYPT_ACQUIRE_ONLY_NCRYPT_KEY_FLAG
                $pdwKeySpec = 0
                $pfCallerFreeProv = $false
                if (![PKI.PfxTools]::CryptAcquireCertificatePrivateKey(
                    $cert.Handle,
                    $dwFlags,
                    0,
                    [ref]$phCryptProv,
                    [ref]$pdwKeySpec,
                    [ref]$pfCallerFreeProv)) {continue}
                $hresult = [PKI.PfxTools]::NCryptDeleteKey($phCryptProv,0)
                if ($hresult -ne 0) {
                    $message = "Cert '{0}' failed: {1}" -f $cert.Thumbprint,
                        (New-Object ComponentModel.Win32Exception $hresult).Message
                    Write-Warning $message
                }
            } else {
                # if the key is legacy, then just read the PrivateKey object of the X509Certificate2 object
                $phProv = [IntPtr]::Zero
                if (![PKI.PfxTools]::CryptAcquireContext(
                    [ref]$phProv,
                    $cert.PrivateKey.CspKeyContainerInfo.KeyContainerName,
                    $cert.PrivateKey.CspKeyContainerInfo.ProviderName,
                    $cert.PrivateKey.CspKeyContainerInfo.ProviderType,
                    $CRYPT_DELETEKEYSET))
                {
                        $hresult = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
                        $message = "Cert '{0}' failed: {1}" -f $cert.Thumbprint,
                            (New-Object ComponentModel.Win32Exception $hresult).Message
                        Write-Warning $message
                }
            }           
        }
    }
}

The usage is pretty simple: just pass a certificate object, and the function will delete private key when necessary. The function performs all required checks to properly handle various scenarios. Few considerations should be taken into account prior to using this function:

  1. This function DO NOT delete certificate from the store. The whole purpose of this function is to delete the private key.
  2. If two or more certificates share the same key, all they will stop working.
  3. If the key is stored on a hardware (smart card, HSM), the function call may raise a PIN dialog, where you will have to enter the PIN to access the private key.

HTH


Share this article:

Comments:

Dr.Sigmund
Dr.Sigmund 27.04.2018 16:41 (GMT+2) How to properly delete certificate with private key in PowerShell

The function accepts an array of certificates and should be able to delete all their keys. But then in the line

if (!$cert.HasPrivateKey) {return}

shouldn't it be "continue" instead of "return"? Otherwise it stops after first certificate which doesn't have private key.

Dr.Sigmund
Dr.Sigmund 27.04.2018 19:36 (GMT+2) How to properly delete certificate with private key in PowerShell

Also, the statement that certutil doesn't delete private key is not completely true. If you delete a certificate using "certutil.exe -delstore" command then yes, private key remains in the system. But there is also "certutil.exe -delkey" which deletes private key according to passed container name. Note that this command is a kind of hidden - it is not show in "certutil.exe /?".

Vadims Podāns
Vadims Podāns 27.04.2018 21:55 (GMT+2) How to properly delete certificate with private key in PowerShell

Thanks, fixed the code.


Post your comment:

Please, solve this little equation and enter result below. Captcha