|
#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
|
|
}
|