HOWTO: Implement PowerShell Certificates End-To-End

I needed to run a PowerShell script on a few dozen machines scattered across just as many disconnected networks. I wanted to ensure that if anyone in the future attempted to make changes to the script that it would no longer execute.  This means learning how to implement PowerShell certificates.  After much Googling I found that there was no good end-to-end guide on implementing certificates.  After much trial and error, I have figured out how to implement PowerShell certificates in such a way that you do NOT need to purchase a commercial certificate while still being able to run the script on remote systems.  I figured I would share the process in the hopes that I can save the next person the frustration I had.

Disclaimer:  These steps are presented without any warranty, express or implied.  As far as I have been able to determine, this process should drastically improve the security of your scripts without otherwise introducing any new security issues.  However as I am still learning about certificates, I may have missed something.  If you do find such a security concern, please let me know as I’d love to know what I missed!

Note: The commands below use the “pki” module for PowerShell 4 and therefore requires Windows 8.1 / Windows 2012

If a modern OS is not available, these same steps can be completed through a combination of legacy tools (makecert.exe and certmgr.msc)

Specific steps on completing this with a legacy OS are not covered in this document

How the Certificate Creation Script Works

  • Creates a custom self-signed certificate on the local machine where the script authoring takes place
  • The entire key (public+private) is exported for archival and safekeeping
  • The public key of this certificate is then exported and immediately reimported into both the Root and Trusted Publisher certificate stores on the authoring computer/user
    This makes this certificate implicitly trusted on the authoring computer which makes it eligible to be used to sign a PowerShell script
  • The newly created certificate is then used to sign a custom PowerShell script
  • The public certificate is then imported onto the target/remote system where the script is intended to be executed
    The target system is assumed to be running an ExecutionPolicy of “AllSigned” which requires that all scripts must be signed by an approved entity before it is executed

Let’s start with our script.  We have a file called c:\ssl\testscript1.ps1 that contains the command we wish to execute on the remote system.

image

On the target system, we verify that the execution policy is in fact set to AllSigned.  We copy the script to the target system and try to execute it.
We receive the error “[script] is not digitally signed.”

image

The rest of this article describes how to get this script to run on the remote system without compromising security.

Note: This script will automatically download and import the New-SelfSignedCertificateEx from the Microsoft Script Gallery

The first thing we need to do is copy, paste and save the custom PowerShell module I have created to automate these steps.  You can find that at the end of this HOWTO

  • Once downloaded, open an administrator PowerShell prompt, navigate to the downloaded file and type:
 Import-Module PSCertificates.ps1
  • Next type
New-CustomCertificate
  • No parameters are required.  However the script will ask you for the name and expiry of the certificate to be generated
  • The script will then generate the certificate, export the public/private key for disaster recovery and also export the public key to be imported into other systems

image

  • We can see that the script added the new certificate to the Personal certificate store on the local computer and exported both the private and public keys as shown in the explorer view below
  • It also placed a password on the exported private key certificate.  Be sure to keep the password known as it will be required to restore the certificate

Note: New-SelfSignedCertificateEx.ps1 is the third party script downloaded from the Microsoft script gallery

image

  • If we open Certificate Manager (certmgr.msc), we can see the certificate present

image

  • Next we need to import the new public part of the certificate into both the Root and the TrustedPublishers store for the CurrentUserThis step is necessary so that PowerShell will be able to successfully sign the script using this certificate.

We use “CurrentUser” rather than “LocalMachine” to limit the scope of impact should this certificate be compromised.

Import-CustomCertificate [pathtopubliccert]

You will get a warning dialog box asking for confirmation to install the certificate.  Choose Yes

image

image

  • Run the command below to review the code signing certificates now available
Get-ChildItem Cert:\CurrentUser\My –CodeSigningCert

image

  • We are now ready to sign our script.  Type:
Sign-PowerShellScript –ScriptToSign [pathtoscript] –CertToUse [certname]

image

Note: If you do not add the certificate to both the Root and Trusted Publishers store, the status of this operation will report as “UnknownError”

If we view our script now, we’ll see that a “signature” block has been added to the file

image

At this point, you now need to distribute the publio key to the machines that will run your script.  Remember it must go into both the Root and TrustedPublisher stores.  This can be done through Group Policy.  In my case however since it’s only a relatively few number of machines with no common management network, I used the Import-CustomCertificate function included.

With the public certificate present on the remote system, the script now executes as desired even with the ExecutionPolicy set to “AllSigned”. This is useful because now if anyone attempts to change so much as one character in the script, it will fail to execute.

image

 

PSCertificates.ps1

 

 

# Downloads a Certificate Signing Module, Creates a new certificate and prompts to sign a PowerShell script
# Home; Certificate; Download; Zip; Dialog; Select File; Test Administrator;

# Script requires pki module that is only available in PowerShell 4 (Windows 8.1/Server 2012)
#requires -version 4

Function Test-Administrator  
{  
    $user = [Security.Principal.WindowsIdentity]::GetCurrent();
    (New-Object Security.Principal.WindowsPrincipal $user).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)  
}

Function Show-FolderDialog ($Msg)
{
    Add-Type -AssemblyName System.Windows.Forms
    $FolderBrowser = New-Object System.Windows.Forms.FolderBrowserDialog
    $FolderBrowser.Description = $Msg
    $FolderBrowser.RootFolder = [System.Environment+SpecialFolder]'MyComputer'
    [void]$FolderBrowser.ShowDialog()
    return $FolderBrowser.SelectedPath
}

Function Show-FileDialog ($Msg)
{
    [System.Reflection.Assembly]::LoadWithPartialName("System.windows.forms") | Out-Null
    $dialog = New-Object System.Windows.Forms.OpenFileDialog
    $dialog.DefaultExt = '.ps1'
    $dialog.Filter = 'PowerShell-Scripts|*.ps1|All Files|*.*'
    $dialog.FilterIndex = 0
    $dialog.InitialDirectory = $env:SystemDrive
    $dialog.Multiselect = $false
    $dialog.RestoreDirectory = $true
    $dialog.Title = $Msg
    $dialog.ValidateNames = $true
    $dialog.ShowDialog()

    return $dialog.FileName
}

Function Expand-ZIPFile ($File, $Destination)
{
    $shell = new-object -com shell.application
    $zip = $shell.NameSpace($file)
    foreach($item in $zip.items())
        { $shell.Namespace($destination).copyhere($item) }
}

Function New-CustomCertificate
{
    # The URL to download the New-SelfSignedCertificateEX module
    $DownloadURL = "https://gallery.technet.microsoft.com/scriptcenter/Self-signed-certificate-5920a7c6/file/101251/1/New-SelfSignedCertificateEx.zip"

    Write-host ""
    Write-Host "[MSG] Testing if user is running as an administrator..." -ForegroundColor Green
    Write-host ""

    # The name of the certificate (this will appear in the Name and Friendlyname fields)
    $CertName = Read-Host -Prompt "Enter the name of the certificate to create (eg: 'CompanyName Code Signing Certificate')"

    # The number of years the certificate will be valid for
    try { [int]$YearsValid = Read-Host "Enter the number of years the certificate will be valid (Recommended: 5)" }
    catch { Write-Error "Invalid input detected"; break } 

    Write-host ""
    Write-Host "[MSG] Showing dialog box to select folder where certificates will be placed..." -ForegroundColor Green
    Write-Warning "If the script appears to hang, check that the Windows confirmation dialog box isn't hidden behind another window."
    Write-host ""
    $WorkingDir = Show-FolderDialog "Please select the working directory where you would like the certificates to be placed (Must be empty):"
    if(!($WorkingDir)) { break } 

    if((Get-ChildItem $WorkingDir).count -gt 0) { Write-Error "$WorkingDir is not empty. The selected folder must be empty"; break }

    # Extract just the filename as we need to specify the filename on the local system to download the file as
    $ZipFileName = Split-Path $DownloadURL -Leaf

    # When we try to import the file we need to change the file name from .zip to .ps1
    # Note that this assumes that the zip file downloaded contains one file and the name of that file matches the zip
    $ScriptName = $ZipFileName -replace ".zip", ".ps1"

    # The Invoke-Expression Command returns an error even though it seems to work.  We cheat for now and simply ignore all errors
    Write-Host "[MSG] Downloading external module used to generate custom certificates..." -ForegroundColor Green
    try {Invoke-Expression (New-Object Net.WebClient).DownloadFile($DownloadURL, "$WorkingDir\$ZipFileName")}catch{}

    # Use the built in Windows Zip capabilities to uncompress the file and save it to the working directory
    Write-Host "[MSG] Unzipping module archive..." -ForegroundColor Green
    Expand-ZIPFile -File "$WorkingDir\$ZipFileName" -Destination $WorkingDir

    # Import the module so we can use the command.  NOTE that this will prompt the user to approve
    Write-Host "[MSG] Importing module '$ScriptName'..." -ForegroundColor Green
    Remove-Module $WorkingDir\$ScriptName -ErrorAction SilentlyContinue | Out-Null
    Import-module $WorkingDir\$ScriptName -Verbose

    # Remove the zip file since we don't need it anymore
    Write-Host "[MSG] Deleting temporary zip file $ZipFileName..." -ForegroundColor Green
    Remove-Item "$WorkingDir\$ZipFileName" -Force

    # Specify the parameters for the new certificate
    $CertParams = @{
    Subject = "CN=$CertName"
    EKU = "Code Signing"
    KeySpec = "Signature" 
    KeyUsage = "DigitalSignature" 
    FriendlyName = $CertName 
    NotAfter = ([datetime]::now.AddYears($YearsValid)) 
    Exportable = $true}

    # Generate a new certificate using the validates specified
    Write-Host "[MSG] Generating certifcate based on supplied paramaters..." -ForegroundColor Green
    New-SelfsignedCertificateEx @CertParams

    # Specify the name for the .PFX export that includes the private key and is intended for disaster recovery"
    $ExportSecureFile = "PROTECT-THIS-FILE.pfx"

    # Specify the name of the public key that will be imported onto all systems that require it
    $PublicCertFile = "PublicKey.cer"

    # Prompt the user for the password to assign to the exported certificate file that includes the private key
    Write-host ""
    $Certpwd = Read-host "Enter the password to be used to secure the exported certificate" -AsSecureString 

    # Export the newly created certificate -- including the private key -- to a file and place a password on that file
    Write-Host "[MSG] Exporting certificate with private key for disaster recovery purposes..." -ForegroundColor Green
    if(Test-Path "$WorkingDir\$ExportSecureFile") { Write-Error "$WorkingDir\$PublicCertFile already exists.  Delete it and try again."; break }
    Get-ChildItem -Path cert:\CurrentUser\my\ | Where {$_.Subject -match $CertName} | Export-PfxCertificate -FilePath "$WorkingDir\$ExportSecureFile" -Password $Certpwd | Out-Null

    # Export the newly created certificate again, only this time export only the public key portion that will be used to import everywhere else
    Write-Host "[MSG] Exporting the public certificate to import on other systems..." -ForegroundColor Green
    if(Test-Path "$WorkingDir\$PublicCertFile") { Write-Error "$WorkingDir\$PublicCertFile already exists.  Delete it and try again."; break }
    Get-ChildItem -Path cert:\CurrentUser\my\ | Where {$_.Subject -match $CertName} | Export-Certificate -FilePath "$WorkingDir\$PublicCertFile" | Out-Null
}

Function Import-CustomCertificate ($CertPath)
{
    # Take your newly exported public key and import it into both the Root and Trusted Publisher stores as both are required for PowerShell to validate a signed script
    Write-Host "[MSG] Importing public certificate into Root and TrustedPublisher stores..." -ForegroundColor Green
    Write-Warning "If the script appears to hang, check that the Windows confirmation dialog box isn't hidden behind another window."
    Import-Certificate -FilePath $CertPath -CertStoreLocation cert:\CurrentUser\Root | Out-Null
    Import-Certificate -FilePath $CertPath -CertStoreLocation cert:\CurrentUser\TrustedPublisher | Out-Null

    Write-Host "[MSG] Showing dialog window for the user to select a PowerShell script to sign..." -ForegroundColor Green
}

Function Sign-PowerShellScript($ScriptToSign, $CertToUse)
{
    if(!($ScriptToSign)) { Write-error "Script not provided and required."; break }
    if(!($CertTouse)) { Write-error "Certificate name to use not provided and required."; break }

    # Now that we have our certificate in place, prompt the user to select the script they would like to sign
    # REMOVED DIALOG BOX TO FOCUS ON COMMAND LINE INPUT
    # $ScriptToSign = Show-FileDialog "Select the PowerShell script to sign"
    #if(!($ScriptToSign)) { break }

    # Select the certifcate that has been marked as valid for Code Signing and matches the exact name of the certificate provided
    $Cert = Get-ChildItem -path cert:\CurrentUser\my -CodeSigningCert | Where {$_.Subject -match $CertToUse}
    $CertCount = ($Cert | Measure-Object).count
    if($CertCount -ne 1) { Write-error "More than one certificate with the name provided exists.  Review the certificates and try again."; break }

    # Sign the selected PowerShell script with the newly created certificate
    Write-Host "[MSG] Signing $ScriptToSign with newly created certificate..." -ForegroundColor Green
    try { Set-AuthenticodeSignature -FilePath $ScriptToSign -certificate $Cert -ErrorAction SilentlyContinue  }
    catch 
    { 
        [System.IO.FileNotFoundException] 
    }
    Finally
    { Write-host $_ }
}

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.