334 lines
17 KiB
PowerShell
334 lines
17 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Requests a SQL Server TLS certificate for vsql from PESCO-PSYLOCKE-CA and binds it to the instances.
|
|
|
|
.DESCRIPTION
|
|
Run on the SQL Server host (the machine behind vsql.pesco.com) in an elevated PowerShell, as a
|
|
domain account that may enroll from the chosen template. Steps:
|
|
|
|
1. Resolve the SQL Server instances to configure and their service accounts. Unknown names
|
|
in -Instances are an error, nothing is touched.
|
|
2. Write a certreq INF with what SQL Server requires (Microsoft certificate requirements):
|
|
a legacy CryptoAPI provider, Microsoft RSA SChannel Cryptographic Provider, with KeySpec
|
|
AT_KEYEXCHANGE; RSA 2048; SHA-256; Server Authentication EKU; key usage digital signature
|
|
and key encipherment; subject CN=vsql.pesco.com; subject alternative names vsql.pesco.com,
|
|
vsql, the host FQDN and short name; machine store; exportable.
|
|
3. Submit to the CA and install the issued certificate. A pending request is saved in a state
|
|
file; issue it on the CA, then rerun with -CerFile (or -Thumbprint once installed).
|
|
4. Validate the installed certificate before anything is bound: private key present and a
|
|
CSP exchange key, 2048 bits or more, SHA-256 family signature, valid dates, Server
|
|
Authentication EKU, every required SAN.
|
|
5. Grant the selected instances' service accounts read on the private key. A failure stops
|
|
the run before any instance is changed.
|
|
6. Save each instance's current Certificate value to the state file, then bind the new
|
|
thumbprint (the registry value SQL Server Configuration Manager sets). ForceEncryption is
|
|
left as it is. A failure restores the values already changed.
|
|
7. Print the restart commands, or restart with -RestartInstances (a short VISUAL outage).
|
|
|
|
-WhatIf previews every step, instance bindings and restarts included, without requesting.
|
|
-Rollback restores the Certificate values saved by the last run.
|
|
|
|
.PARAMETER CertificateAuthority
|
|
CA config string host\name. Default DC-02.PESCO.COM\PESCO-PSYLOCKE-CA.
|
|
|
|
.PARAMETER Template
|
|
Certificate template name. Default WebServer. The template must allow Server Authentication
|
|
and must not force a Key Storage Provider.
|
|
|
|
.PARAMETER Instances
|
|
Instance names to configure (MSSQLSERVER for the default instance, DEV for vsql\DEV).
|
|
Default: every instance on this host.
|
|
|
|
.PARAMETER CerFile
|
|
Continue from an issued certificate file (after a pending request was issued and retrieved).
|
|
|
|
.PARAMETER Thumbprint
|
|
Continue from a certificate already in the machine store; skips the request.
|
|
|
|
.PARAMETER RestartInstances
|
|
Restart each configured instance so the certificate takes effect.
|
|
|
|
.PARAMETER Rollback
|
|
Restore the Certificate values saved by the last run and stop.
|
|
|
|
.EXAMPLE
|
|
.\Request-VsqlServerCertificate.ps1 -WhatIf
|
|
.EXAMPLE
|
|
.\Request-VsqlServerCertificate.ps1 -Instances DEV
|
|
.EXAMPLE
|
|
.\Request-VsqlServerCertificate.ps1 -CerFile C:\ProgramData\PacketStation\vsql-cert\vsql-20261018-090000.cer -Instances DEV -RestartInstances
|
|
.EXAMPLE
|
|
.\Request-VsqlServerCertificate.ps1 -Rollback
|
|
#>
|
|
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Request')]
|
|
param(
|
|
[string]$CertificateAuthority = 'DC-02.PESCO.COM\PESCO-PSYLOCKE-CA',
|
|
[string]$Template = 'WebServer',
|
|
[string[]]$DnsNames = @('vsql.pesco.com', 'vsql'),
|
|
[string[]]$Instances,
|
|
[Parameter(ParameterSetName = 'Cer')][string]$CerFile,
|
|
[Parameter(ParameterSetName = 'Thumb')][string]$Thumbprint,
|
|
[switch]$RestartInstances,
|
|
[Parameter(ParameterSetName = 'Rollback')][switch]$Rollback
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$acceptedSignatures = @('sha256RSA', 'sha384RSA', 'sha512RSA')
|
|
$work = Join-Path $env:ProgramData 'PacketStation\vsql-cert'
|
|
$stateFile = Join-Path $work 'state.json'
|
|
$instanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
|
|
|
|
function Test-Admin {
|
|
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
}
|
|
|
|
function Read-State {
|
|
if (Test-Path $stateFile) { Get-Content $stateFile -Raw | ConvertFrom-Json } else { [pscustomobject]@{} }
|
|
}
|
|
|
|
function Write-State([object]$state) {
|
|
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
|
$state | ConvertTo-Json -Depth 5 | Set-Content -Path $stateFile -Encoding UTF8
|
|
}
|
|
|
|
function Get-SqlInstances([string[]]$wanted) {
|
|
$map = Get-ItemProperty "$instanceRoot\Instance Names\SQL" -ErrorAction SilentlyContinue
|
|
if (-not $map) { throw 'No SQL Server Database Engine instance is registered on this host. Is this the vsql machine?' }
|
|
$all = @($map.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
|
|
$name = $_.Name
|
|
$svc = if ($name -eq 'MSSQLSERVER') { 'MSSQLSERVER' } else { "MSSQL`$$name" }
|
|
$service = Get-CimInstance Win32_Service -Filter "Name='$($svc.Replace('$','\$'))'" -ErrorAction SilentlyContinue
|
|
[pscustomobject]@{
|
|
Name = $name
|
|
Id = $_.Value
|
|
Service = $svc
|
|
Account = if ($service) { $service.StartName } else { $null }
|
|
RegKey = "$instanceRoot\$($_.Value)\MSSQLServer\SuperSocketNetLib"
|
|
}
|
|
})
|
|
if ($wanted) {
|
|
$unknown = @($wanted | Where-Object { $_ -notin $all.Name })
|
|
if ($unknown) { throw "Unknown instance(s): $($unknown -join ', '). Found on this host: $($all.Name -join ', ')" }
|
|
$all = @($all | Where-Object { $wanted -contains $_.Name })
|
|
}
|
|
foreach ($i in $all) {
|
|
if (-not $i.Account) { throw "Service $($i.Service) for instance $($i.Name) was not found." }
|
|
if (-not (Test-Path $i.RegKey)) { throw "Registry key for instance $($i.Name) not found: $($i.RegKey)" }
|
|
}
|
|
return $all
|
|
}
|
|
|
|
function Test-ServerCertificate([System.Security.Cryptography.X509Certificates.X509Certificate2]$cert, [string[]]$requiredNames) {
|
|
$problems = New-Object System.Collections.Generic.List[string]
|
|
if (-not $cert.HasPrivateKey) { $problems.Add('no private key in the machine store') }
|
|
$csp = $null
|
|
if ($cert.HasPrivateKey) {
|
|
$pk = $cert.PrivateKey
|
|
if ($pk -is [System.Security.Cryptography.RSACryptoServiceProvider]) {
|
|
$csp = $pk.CspKeyContainerInfo
|
|
if ($csp.KeyNumber -ne [System.Security.Cryptography.KeyNumber]::Exchange) { $problems.Add("KeySpec is $($csp.KeyNumber), SQL Server needs AT_KEYEXCHANGE") }
|
|
if ($csp.ProviderType -ne 12) { $problems.Add("provider type $($csp.ProviderType) ($($csp.ProviderName)); expected 12, Microsoft RSA SChannel Cryptographic Provider") }
|
|
}
|
|
else {
|
|
$problems.Add('the private key is not a CryptoAPI (CSP) key; a KSP/CNG key is not supported by SQL Server')
|
|
}
|
|
}
|
|
$keySize = $cert.PublicKey.Key.KeySize
|
|
if ($keySize -lt 2048) { $problems.Add("key size $keySize is below 2048") }
|
|
$sig = $cert.SignatureAlgorithm.FriendlyName
|
|
if ($sig -notin $acceptedSignatures) { $problems.Add("signature algorithm $sig is not one of $($acceptedSignatures -join ', ')") }
|
|
$now = Get-Date
|
|
if ($cert.NotBefore -gt $now -or $cert.NotAfter -lt $now.AddDays(30)) { $problems.Add("validity $($cert.NotBefore.ToString('yyyy-MM-dd')) to $($cert.NotAfter.ToString('yyyy-MM-dd')) is not usable") }
|
|
$eku = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' }
|
|
if (-not $eku -or -not ($eku.EnhancedKeyUsages | Where-Object { $_.Value -eq '1.3.6.1.5.5.7.3.1' })) { $problems.Add('Server Authentication EKU missing') }
|
|
$ku = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.15' }
|
|
if ($ku -and -not ($ku.KeyUsages -band [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment)) { $problems.Add('key usage lacks key encipherment') }
|
|
$san = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' }
|
|
$sanText = if ($san) { $san.Format($false) } else { '' }
|
|
foreach ($n in $requiredNames) {
|
|
if ($sanText -notmatch [regex]::Escape("DNS Name=$n")) { $problems.Add("subject alternative name $n missing") }
|
|
}
|
|
return [pscustomobject]@{ Problems = $problems; Csp = $csp; KeySize = $keySize; Signature = $sig }
|
|
}
|
|
|
|
# --- rollback --------------------------------------------------------------------------------
|
|
if (Test-Admin -eq $false) { throw 'Run this in an elevated PowerShell on the SQL Server host.' }
|
|
|
|
if ($Rollback) {
|
|
$state = Read-State
|
|
if (-not $state.previous) { throw "No saved bindings in $stateFile." }
|
|
foreach ($p in $state.previous.PSObject.Properties) {
|
|
$inst = $p.Name; $value = $p.Value.value; $key = $p.Value.regKey
|
|
if ($PSCmdlet.ShouldProcess("instance $inst", "restore Certificate='$value'")) {
|
|
if ([string]::IsNullOrEmpty($value)) { Remove-ItemProperty -Path $key -Name Certificate -ErrorAction SilentlyContinue }
|
|
else { Set-ItemProperty -Path $key -Name Certificate -Value $value }
|
|
Write-Host "Restored $inst to '$value'"
|
|
}
|
|
}
|
|
Write-Host 'Restart the instances for the rollback to take effect.'
|
|
return
|
|
}
|
|
|
|
# --- 1. instances ------------------------------------------------------------------------------
|
|
$targets = Get-SqlInstances $Instances
|
|
$fqdn = [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName
|
|
$names = @($DnsNames + $fqdn + $env:COMPUTERNAME) | Select-Object -Unique
|
|
$primary = $DnsNames[0]
|
|
Write-Host "Host $fqdn. Instances: $(($targets | ForEach-Object { "$($_.Name) [$($_.Account)]" }) -join ', ')"
|
|
Write-Host "Certificate CN=$primary, SANs: $($names -join ', ')"
|
|
|
|
$cert = $null
|
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
|
|
|
if ($PSCmdlet.ParameterSetName -eq 'Thumb') {
|
|
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $Thumbprint.ToUpper() }
|
|
if (-not $cert) { throw "Certificate $Thumbprint is not in the machine store." }
|
|
}
|
|
elseif ($PSCmdlet.ParameterSetName -eq 'Cer') {
|
|
if (-not (Test-Path $CerFile)) { throw "Certificate file not found: $CerFile" }
|
|
$issued = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $CerFile
|
|
if ($PSCmdlet.ShouldProcess($CerFile, 'certreq -accept')) {
|
|
& certreq -accept -q $CerFile | Out-Host
|
|
if ($LASTEXITCODE -ne 0) { throw "certreq -accept failed ($LASTEXITCODE). The request that made this certificate must exist on this host." }
|
|
}
|
|
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $issued.Thumbprint }
|
|
if (-not $cert -and -not $WhatIfPreference) { throw "Issued certificate $($issued.Thumbprint) not found in the machine store after accept." }
|
|
}
|
|
else {
|
|
# --- 2. INF ---------------------------------------------------------------------------------
|
|
$inf = Join-Path $work "vsql-$stamp.inf"
|
|
$csr = Join-Path $work "vsql-$stamp.req"
|
|
$cer = Join-Path $work "vsql-$stamp.cer"
|
|
$sanLines = ($names | ForEach-Object { "_continue_ = `"dns=$_&`"" }) -join "`r`n"
|
|
$infText = @"
|
|
[Version]
|
|
Signature = "`$Windows NT`$"
|
|
|
|
[NewRequest]
|
|
Subject = "CN=$primary"
|
|
KeyLength = 2048
|
|
KeyAlgorithm = RSA
|
|
HashAlgorithm = SHA256
|
|
; SQL Server needs a legacy CryptoAPI provider and an exchange key (KeySpec = AT_KEYEXCHANGE).
|
|
ProviderName = "Microsoft RSA SChannel Cryptographic Provider"
|
|
ProviderType = 12
|
|
KeySpec = 1
|
|
; digital signature + key encipherment
|
|
KeyUsage = 0xA0
|
|
Exportable = TRUE
|
|
MachineKeySet = TRUE
|
|
RequestType = PKCS10
|
|
FriendlyName = "SQL Server TLS $primary ($stamp)"
|
|
|
|
[EnhancedKeyUsageExtension]
|
|
OID = 1.3.6.1.5.5.7.3.1
|
|
|
|
[Extensions]
|
|
2.5.29.17 = "{text}"
|
|
$sanLines
|
|
|
|
[RequestAttributes]
|
|
CertificateTemplate = $Template
|
|
"@
|
|
Write-Host "INF: $inf"
|
|
if ($WhatIfPreference) {
|
|
Write-Host $infText
|
|
Write-Host "WhatIf: would run certreq -new, -submit to $CertificateAuthority (template $Template) and -accept."
|
|
}
|
|
else {
|
|
$infText | Set-Content -Path $inf -Encoding ASCII
|
|
# --- 3. request, submit, accept -------------------------------------------------------
|
|
& certreq -new -q $inf $csr | Out-Host
|
|
if ($LASTEXITCODE -ne 0) { throw "certreq -new failed ($LASTEXITCODE)" }
|
|
$submit = & certreq -submit -q -config $CertificateAuthority $csr $cer 2>&1
|
|
$submit | Out-Host
|
|
$reqId = ($submit | Select-String -Pattern 'RequestId:\s*"?(\d+)' | ForEach-Object { $_.Matches[0].Groups[1].Value } | Select-Object -First 1)
|
|
$state = Read-State
|
|
$state | Add-Member -NotePropertyName request -NotePropertyValue ([pscustomobject]@{ id = $reqId; ca = $CertificateAuthority; cer = $cer; inf = $inf; at = (Get-Date).ToString('s') }) -Force
|
|
Write-State $state
|
|
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $cer)) {
|
|
throw "The request was not issued (certreq exit $LASTEXITCODE, request id $reqId). If it is pending, have the CA admin issue it, then on this host run: certreq -retrieve -config `"$CertificateAuthority`" $reqId `"$cer`" and rerun this script with -CerFile `"$cer`"$(if ($Instances) { " -Instances $($Instances -join ',')" })."
|
|
}
|
|
& certreq -accept -q $cer | Out-Host
|
|
if ($LASTEXITCODE -ne 0) { throw "certreq -accept failed ($LASTEXITCODE)" }
|
|
$issued = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $cer
|
|
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $issued.Thumbprint }
|
|
if (-not $cert) { throw "Issued certificate $($issued.Thumbprint) not found in the machine store." }
|
|
}
|
|
}
|
|
|
|
if ($WhatIfPreference -and -not $cert) {
|
|
Write-Host "WhatIf: would validate the certificate, grant private key read to $(($targets.Account | Select-Object -Unique) -join ', '), bind it to $(($targets.Name) -join ', ') and $(if ($RestartInstances) { 'restart' } else { 'print restart commands for' }) $(($targets.Service) -join ', ')."
|
|
return
|
|
}
|
|
|
|
# --- 4. validate ------------------------------------------------------------------------------
|
|
$thumb = $cert.Thumbprint.ToLower()
|
|
$check = Test-ServerCertificate $cert $names
|
|
Write-Host "Certificate $($cert.Subject) thumbprint $thumb, $($check.KeySize)-bit, $($check.Signature), expires $($cert.NotAfter.ToString('yyyy-MM-dd'))"
|
|
if ($check.Problems.Count) {
|
|
throw "The certificate is not usable by SQL Server:`n - $($check.Problems -join "`n - ")`nNothing was bound. Check the template ($Template) on the CA; it must issue through a legacy CSP with an exchange key."
|
|
}
|
|
|
|
# --- 5. private key access, per selected instance account ------------------------------------
|
|
$keyName = $check.Csp.UniqueKeyContainerName
|
|
$keyFile = Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys\$keyName"
|
|
if (-not (Test-Path $keyFile)) { throw "Private key container file not found: $keyFile" }
|
|
foreach ($acct in ($targets.Account | Select-Object -Unique)) {
|
|
if ($PSCmdlet.ShouldProcess($keyFile, "grant read to $acct")) {
|
|
$out = & icacls $keyFile /grant "${acct}:(R)" 2>&1
|
|
if ($LASTEXITCODE -ne 0) { throw "Could not grant $acct read on the private key ($out). Nothing was bound." }
|
|
Write-Host "Private key read granted to $acct"
|
|
}
|
|
}
|
|
|
|
# --- 6. bind, with saved previous values ---------------------------------------------------------
|
|
$state = Read-State
|
|
$previous = [ordered]@{}
|
|
foreach ($i in $targets) {
|
|
$cur = (Get-ItemProperty $i.RegKey -Name Certificate -ErrorAction SilentlyContinue).Certificate
|
|
$previous[$i.Name] = [pscustomobject]@{ regKey = $i.RegKey; value = $cur }
|
|
}
|
|
$state | Add-Member -NotePropertyName previous -NotePropertyValue ([pscustomobject]$previous) -Force
|
|
$state | Add-Member -NotePropertyName bound -NotePropertyValue ([pscustomobject]@{ thumbprint = $thumb; at = (Get-Date).ToString('s'); instances = @($targets.Name) }) -Force
|
|
Write-State $state
|
|
Write-Host "Previous bindings saved to $stateFile (rollback: .\Request-VsqlServerCertificate.ps1 -Rollback)"
|
|
|
|
$done = @()
|
|
try {
|
|
foreach ($i in $targets) {
|
|
if ($PSCmdlet.ShouldProcess("instance $($i.Name)", "set Certificate=$thumb")) {
|
|
Set-ItemProperty -Path $i.RegKey -Name Certificate -Value $thumb
|
|
$done += $i
|
|
$force = (Get-ItemProperty $i.RegKey -Name ForceEncryption -ErrorAction SilentlyContinue).ForceEncryption
|
|
Write-Host "Instance $($i.Name): Certificate bound (ForceEncryption=$force, unchanged)"
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
foreach ($i in $done) {
|
|
$old = $previous[$i.Name].value
|
|
if ([string]::IsNullOrEmpty($old)) { Remove-ItemProperty -Path $i.RegKey -Name Certificate -ErrorAction SilentlyContinue } else { Set-ItemProperty -Path $i.RegKey -Name Certificate -Value $old }
|
|
Write-Warning "Restored $($i.Name) to its previous binding."
|
|
}
|
|
throw
|
|
}
|
|
|
|
# --- 7. restart ------------------------------------------------------------------------------
|
|
if ($RestartInstances) {
|
|
foreach ($i in $targets) {
|
|
if ($PSCmdlet.ShouldProcess($i.Service, 'restart service')) { Restart-Service -Name $i.Service -Force; Write-Host "Restarted $($i.Service)" }
|
|
}
|
|
}
|
|
else {
|
|
Write-Host ''
|
|
Write-Host 'Restart the instances for the certificate to take effect (off-hours; VISUAL users lose their connection):'
|
|
$targets | ForEach-Object { Write-Host " Restart-Service -Name '$($_.Service)' -Force" }
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host "Done. Thumbprint $thumb. After the restart the SQL Server error log should read 'The certificate [Cn = $primary] was successfully loaded for encryption'."
|
|
Write-Host 'If an instance fails to start, run this script with -Rollback and restart it, then check the private key access and the provider.'
|
|
Write-Host 'Then tell Packet Station: the stacks switch to VISUAL_ENCRYPT=yes (Gitea issue 5).'
|