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