<# .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).'