Skip to content

Instantly share code, notes, and snippets.

@joshooaj
Last active August 7, 2023 17:59
Show Gist options
  • Save joshooaj/237dad59de3446720c683c82aceaa0ee to your computer and use it in GitHub Desktop.
Save joshooaj/237dad59de3446720c683c82aceaa0ee to your computer and use it in GitHub Desktop.
New-VmsSelfSignedCertificate

New-VmsSelfSignedCertificate

Introduction

This script was written to make it easier to generate self-signed certificates, especially in a multi-server and/or multi-client environment where it is much easier when the certificates are signed by a common root certificate authority.

This is not intended as a replacement for good certificate management using either a well-managed private certificate authority, or certificates signed by public certificate authorities.

Usage - First machine

  1. Download New-VmsSelfSignedCertificate.ps1 and extract the ZIP file. You might need to right-click on the .ZIP, or the extracted .PS1 file, select properties, and "unblock" the file. This may not apply when using a different browser than Internet Explorer.
  2. Open an elevated PowerShell terminal (run as Administrator) and call the script by entering the path to the script and pressing enter.
  3. A SaveFileDialog prompt will ask you to provide a path and file name for a new .PFX file - this will contain the certificate used to sign the web certificate generated by the script. You will also be asked to provide a password (twice) which will be used to protect the PFX file.
  4. A new web server certificate will now be listed in Cert:\LocalMachine\My certificate store.

You may now use Milestone's Server Configurator application to set, or update the certificate used by the VMS.

Usage - Each additional machine

If you have multiple servers to enable encryption on, then on each additional machine you can copy the PFX file created on the first machine, and invoke the script with the -SignerPfxFile parameter containing a path to the PFX file. This way, each machine will trust the certificates from each other machine.

#Requires -RunAsAdministrator
#Requires -Modules pki
#Requires -Assembly System.Windows.Forms
<#
.SYNOPSIS
Creates a new web server certificate signed by a self-signed "root" signing certificate.
.DESCRIPTION
This script creates a new web server certificate and adds it to the
Cert:\LocalMachine\My certificate store, where it can be used by a Milestone
XProtect VMS component (or any other Windows application).
The certificate will include the local computer's hostname and fully qualified
domain name in the subject alternative names list, as well as any custom value
provided for the "DnsName" parameter.
The `New-SelfSignedCertificate` cmdlet provided with the built-in pki module
is easy enough to use on it's own, but this script will also:
- Sign the certificate with the supplied SignerPfxFile or...
- Generate a certificate signing certificate if no existing SignerPfxFile is provided.
- Add the certificate signing certificate to the trusted root certificates store.
- Grant the Network Service account, or the manually provided service account(s) permission to read the certificate private key.
The advantage of generating, or using an existing root certificate is that it
simplifies setting up trust in your environment. If multiple servers are involved,
you may run the script on the first server without providing a SignerPfxFile,
a root certificate will be generated for you, and you'll be asked to specify
a path to save the file to disk, and a password to protect the certificate.
This PFX file can then be used when invoking New-VmsSelfSignedCertificate on
each other server. In the end, all servers will trust the common root certificate,
and by association, all certificates signed by this common root certificate.
.PARAMETER SignerPfxFile
Specifies the path to a password-protected PFX file containing a certificate-signing
certificate. If provided, this will be used to sign the web server certificate
generated by the script. Otherwise a new certificate-signing certificate will be
generated and used for this purpose, and you'll be required to provide a path
to save the file, and a password to protect it since it will contain a private
key.
.PARAMETER SignerPfxPassword
Specifies the password to use when importing the certificate-signing certificate.
.PARAMETER DnsName
Specifies one or more DnsName values to include in the web server certificate
subject alternative names list. The hostname will always be included by default.
.PARAMETER FriendlyName
Specifies a user-friendly name for the certificate. This is what will be shown
in Milestone's Server Configurator, and can make it easier to pick the certificate
out from a list of similarly named certificates.
.PARAMETER NotAfter
Specifies a datetime after which the certificate will no longer be valid. The
default expiration for the web server certificate is 1 year.
.PARAMETER ServiceAccount
Specifies one or more Windows or Active Directory accounts which should have
permission to read the private key for the web server certificate.
.PARAMETER KeyExportPolicy
Specifies whether the certificate key should be exportable or not. The default
value is NonExportable.
.EXAMPLE
. .\New-VmsSelfSignedCertificate.ps1
After saving the script to the local machine and opening a PowerShell terminal
to the same folder, this invokes the script with default parameters. You are
asked to provide a path to save the root certificate to a PFX file, and you
are prompted for a password to protect the file. A certificate is then generated
for the local machine's host name, and the certificate is signed by the the
generated root signing certificate. The root certificate has a 5 year expiration
and the leaf certificate has a 1 year expiration. The network service account
will have read access to the private key, and the root certificate will be
added to the trusted root certificate store.
.EXAMPLE
. .\New-VmsSelfSignedCertificate.ps1 -SignerPfXFile ~\Desktop\root-certificate.pfx -SignerPfxPassword (read-host -assecurestring) -DnsName "recorder1.mydomain.local" -FriendlyName "Test Certificate" -NotAfter (Get-Date).AddYears(3) -ServiceAccount "mydomain\vms-service-account" -KeyExportPolicy ExportableEncrypted
After saving the script to the local machine and opening a PowerShell terminal
to the same folder, this invokes the script with an existing PFX file containing
a certificate signing certificate. You will be prompted to provide the PFX
password, and then a certificate will be generated for "recorder1.mydomain.local"
as well as the local machines hostname. The certificate Friendly Name will be
"Test Certificate" and the certificate will expire in 3 years. The service account
"mydomain\vms-service-account" will be granted read permission for the private
key, and the private key will be exportable.
.NOTES
This script is provided as-is. Use it at your own risk.
#>
param(
[Parameter()]
[string]
$SignerPfxFile,
[Parameter()]
[securestring]
$SignerPfxPassword,
[Parameter()]
[string[]]
$DnsName = $env:COMPUTERNAME,
[Parameter()]
[string]
$FriendlyName,
[Parameter()]
[datetime]
$NotAfter = (Get-Date).AddYears(1),
[Parameter()]
[string[]]
$ServiceAccount = @('NT AUTHORITY\NETWORK SERVICE'),
[Parameter()]
[Microsoft.CertificateServices.Commands.KeyExportPolicy]
$KeyExportPolicy = [Microsoft.CertificateServices.Commands.KeyExportPolicy]::NonExportable
)
Add-Type -AssemblyName System.Windows.Forms
Add-Type @'
using System;
using System.Runtime.InteropServices;
public class WindowHelper {
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetForegroundWindow(IntPtr hWnd);
}
'@
function Show-SaveFileDialog {
<#
.SYNOPSIS
Shows the Windows SaveFileDialog and returns the user-provided file path.
.DESCRIPTION
For detailed information on the available parameters, see the SaveFileDialog
class documentation online at https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.savefiledialog?view=netframework-4.8.1
#>
[CmdletBinding()]
[OutputType([string])]
param (
[Parameter()]
[bool]
$AddExtension = $true,
[Parameter()]
[bool]
$AutoUpgradeEnabled = $true,
[Parameter()]
[bool]
$CheckFileExists = $false,
[Parameter()]
[bool]
$CheckPathExists = $true,
[Parameter()]
[bool]
$CreatePrompt,
[Parameter()]
[string[]]
$CustomPlaces,
[Parameter()]
[string]
$DefaultExt,
[Parameter()]
[bool]
$DereferenceLinks = $true,
# Filter for specific file types. Example syntax: 'Excel files (*.xlsx)|*.xlsx|All files (*.*)|*.*'
[Parameter()]
[string]
$Filter,
[Parameter()]
[string]
$InitialDirectory,
[Parameter()]
[bool]
$OverwritePrompt = $true,
[Parameter()]
[bool]
$RestoreDirectory,
[Parameter()]
[bool]
$ShowHelp,
[Parameter()]
[bool]
$SupportMultiDottedExtensions,
[Parameter()]
[string]
$Title,
[Parameter()]
[bool]
$ValidateNames = $true
)
process {
$params = @{
AddExtension = $AddExtension
AutoUpgradeEnabled = $AutoUpgradeEnabled
CheckFileExists = $CheckFileExists
CheckPathExists = $CheckPathExists
CreatePrompt = $CreatePrompt
DefaultExt = $DefaultExt
DereferenceLinks = $DereferenceLinks
Filter = $Filter
InitialDirectory = $InitialDirectory
OverwritePrompt = $OverwritePrompt
RestoreDirectory = $RestoreDirectory
ShowHelp = $ShowHelp
SupportMultiDottedExtensions = $SupportMultiDottedExtensions
Title = $Title
ValidateNames = $ValidateNames
}
[System.Windows.Forms.Form]$form = $null
[System.Windows.Forms.SaveFileDialog]$dialog = $null
try {
$form = [System.Windows.Forms.Form]@{ TopMost = $true }
$dialog = [System.Windows.Forms.SaveFileDialog]$params
$CustomPlaces | ForEach-Object {
if ($null -eq $_) {
return
}
if (($id = $_ -as [guid])) {
$dialog.CustomPlaces.Add($id)
} else {
$dialog.CustomPlaces.Add($_)
}
}
$null = [WindowHelper]::SetForegroundWindow($form.Handle)
if (($dialogResult = $dialog.ShowDialog($form)) -eq 'OK') {
$dialog.FileName
} else {
Write-Error -Message "DialogResult: $dialogResult"
}
} finally {
if ($dialog) {
$dialog.Dispose()
}
if ($form) {
$form.Dispose()
}
}
}
}
function Find-CertificatePrivateKey {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
[System.Security.Cryptography.X509Certificates.X509Certificate2[]]
$Certificate
)
process {
foreach ($cert in $Certificate) {
$keyFileName = $cert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName
if ([string]::IsNullOrWhiteSpace($keyFileName)) {
$rsaCng = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
$keyFileName = $rsaCng.Key.UniqueName
}
if ([string]::IsNullOrWhiteSpace($keyFileName)) {
Write-Error "Failed to locate the private key for certificate $($cert.Subject) with thumbprint $($cert.Thumbprint)" -TargetObject $cert -Category ObjectNotFound
return
}
# Are there any other locations where LocalMachine certificates might be stored?
# Consider adding locations for CurrentUser certificate store as well.
$keyFile = $null
foreach ($folder in 'C:\ProgramData\Microsoft\Crypto\RSA\MachineKeys\', 'C:\ProgramData\Microsoft\Crypto\Keys\') {
$path = Join-Path -Path $folder -ChildPath $keyFileName
if (Test-Path -Path $path) {
$keyFile = $path
break
}
}
if ($null -eq $keyFile) {
Write-Error "Failed to locate the private key file with unique name '$keyFileName' for certificate $($cert.Subject) with thumbprint $($cert.Thumbprint)" -TargetObject $cert -Category ObjectNotFound
return
}
Get-Item -Path $keyFile
}
}
}
function New-VmsSelfSignedCertificate {
[CmdletBinding()]
[OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])]
param(
[Parameter()]
[string[]]
$DnsName,
[Parameter()]
[string]
$FriendlyName,
[Parameter()]
[datetime]
$NotAfter = (Get-Date).AddYears(1),
[Parameter()]
[string[]]
$ServiceAccount = @('NT AUTHORITY\NETWORK SERVICE'),
[Parameter()]
[ValidateScript({
$keyUsage = $_.Extensions | Where-Object { $_ -as [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension] }
if ($keyUsage -and -not ($keyUsage.KeyUsages -band [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign)) {
throw "The certificate provided to sign the VMS certificate is missing the CertSign KeyUsage flag."
}
$true
})]
[System.Security.Cryptography.X509Certificates.X509Certificate2]
$Signer,
[Parameter()]
[Microsoft.CertificateServices.Commands.KeyExportPolicy]
$KeyExportPolicy = [Microsoft.CertificateServices.Commands.KeyExportPolicy]::NonExportable
)
process {
$dnsNames = @{
"$($env:COMPUTERNAME)" = $null
}
try {
$dnsNames[[system.net.dns]::GetHostEntry('localhost').HostName] = $null
$DnsName | ForEach-Object {
if (-not [string]::IsNullOrWhiteSpace($_)) {
$dnsNames[$_] = $null
}
}
} catch {
Write-Warning $_.Exception.Message
}
$certParams = @{
Subject = 'CN={0}' -f $env:COMPUTERNAME
DnsName = $dnsNames.Keys | Select-Object
FriendlyName = if ([string]::IsNullOrWhiteSpace($FriendlyName)) { $env:COMPUTERNAME } else { $FriendlyName }
Type = 'SSLServerAuthentication'
NotAfter = $NotAfter
CertStoreLocation = 'Cert:\LocalMachine\My'
KeyAlgorithm = 'RSA'
KeyLength = 2048
KeyExportPolicy = $KeyExportPolicy
ErrorAction = 'Stop'
}
if ($Signer) {
$certParams.Signer = $Signer
}
Write-Verbose "Creating a certificate for the DNS names $($dnsNames.Keys -join ', ')."
$cert = New-SelfSignedCertificate @certParams
# Update permissions for private key if possible and return the certificate
try {
$acl = $cert | Find-CertificatePrivateKey -ErrorAction Stop | Get-Acl
foreach ($account in $ServiceAccount) {
Write-Verbose "Granting $account Read access on private key"
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($account, 'Read', 'Allow')
$acl.AddAccessRule($rule)
}
$acl | Set-Acl
} catch {
throw $_
} finally {
$cert
}
}
}
function New-VmsSigningCertificate {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]
$FileName,
[Parameter(Mandatory)]
[securestring]
$Password,
[Parameter()]
[string]
$Subject = 'CN=Temporary Certificate Authority',
[Parameter()]
[string]
$DnsName = 'Temporary Certificate Authority',
[Parameter()]
[datetime]
$NotAfter = (Get-Date).AddYears(5)
)
process {
$signerParams = @{
Subject = $Subject
#DnsName = $DnsName
FriendlyName = $DnsName
NotAfter = $NotAfter
CertStoreLocation = 'Cert:\LocalMachine\My'
KeyExportPolicy = 'ExportableEncrypted'
KeyUsage = 'DigitalSignature', 'CertSign'
KeyAlgorithm = 'RSA'
KeyLength = 2048
ErrorAction = 'Stop'
}
Write-Verbose "Creating self-signed root certificate to be used for signing the VMS certificate."
$signer = New-SelfSignedCertificate @signerParams
$null = $signer | Export-PfxCertificate -FilePath $SignerPfxFile -Password $SignerPfxPassword -CryptoAlgorithmOption AES256_SHA256 -ErrorAction Stop
TrustCertificate -Certificate $signer
$signer
}
}
function TrustCertificate {
param($Certificate)
if ($null -eq (Get-ChildItem -Path "Cert:\LocalMachine\Root\$($Certificate.Thumbprint)" -ErrorAction SilentlyContinue)) {
$tempFile = [io.path]::GetTempFileName()
try {
$null = $signer | Export-Certificate -Type CERT -FilePath $tempFile
Write-Verbose "Adding signing certificate to trusted root certificate store"
$null = Import-Certificate -FilePath $tempFile -CertStoreLocation Cert:\LocalMachine\Root
} finally {
if (Test-Path -Path $tempFile) {
Remove-Item -Path $tempFile
}
}
}
}
function Import-VmsSigningCertificate {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]
$SignerPfxFile,
[Parameter()]
[securestring]
$SignerPfxPassword
)
process {
if ($null -eq $SignerPfxPassword) {
$pfxFileName = ([io.fileinfo]$SignerPfxFile).Name
$SignerPfxPassword = Read-Host -Prompt "Password for $pfxFileName" -AsSecureString
}
$signer = Import-PfxCertificate -FilePath $SignerPfxFile -Password $SignerPfxPassword -CertStoreLocation Cert:\LocalMachine\My -Exportable -ErrorAction Stop
TrustCertificate -Certificate $signer
$signer
}
}
if (-not [string]::IsNullOrWhiteSpace($SignerPfxFile)) {
$SignerPfxFile = (Resolve-Path -Path $SignerPfxFile -ErrorAction SilentlyContinue -ErrorVariable rpe).Path
if ($rpe) {
$SignerPfxFile = $rpe.TargetObject
}
}
$signer = $null
try {
if ([string]::IsNullOrWhiteSpace($SignerPfxFile)) {
# Create a new signing certificate
$dialogParams = @{
Title = 'Save a new certificate signing certificate in .PFX format'
DefaultExt = '.pfx'
Filter = 'Personal Information Exchange (*.pfx)|*.pfx'
InitialDirectory = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::Desktop)
}
$SignerPfxFile = Show-SaveFileDialog @dialogParams -ErrorAction Stop
}
if (-not (Test-Path -Path $SignerPfxFile)) {
$pfxFileName = ([io.fileinfo]$SignerPfxFile).Name
while ($null -eq $SignerPfxPassword) {
$pfxPass = Read-Host -Prompt "Set a password for $pfxFileName" -AsSecureString
$confirmPfxPass = Read-Host -Prompt "Confirm new password for $pfxFileName" -AsSecureString
if ([pscredential]::new('a', $pfxPass).GetNetworkCredential().Password -ceq [pscredential]::new('a', $confirmPfxPass).GetNetworkCredential().Password) {
$SignerPfxPassword = $pfxPass
} else {
Write-Warning "Passwords did not match. Please try again."
}
}
$signer = New-VmsSigningCertificate -FileName $SignerPfxFile -Password $SignerPfxPassword
} else {
# Load existing signing certificate from disk
$importParams = @{
SignerPfxFile = $SignerPfxFile
}
if ($SignerPfxPassword) {
$importParams.SignerPfxPassword = $SignerPfxPassword
}
$signer = Import-VmsSigningCertificate @importParams -ErrorAction Stop
}
$certParams = @{
DnsName = $DnsName
NotAfter = $NotAfter
FriendlyName = if ([string]::IsNullOrWhiteSpace($FriendlyName)) { $DnsName[0] } else { $FriendlyName }
ServiceAccount = $ServiceAccount
KeyExportPolicy = $KeyExportPolicy
Signer = $signer
}
New-VmsSelfSignedCertificate @certParams -ErrorAction Stop
} finally {
$signer | Remove-Item -ErrorAction SilentlyContinue
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment