# ==============================================================================
# Update-SQLServerCertificate.ps1
#
# Post-Renewal Task script for Certify The Web / Let's Encrypt
#
# What it does:
# 1. Pulls certificate details from the Certify $result object
# 2. Auto-discovers ALL SQL Server instances via the registry
# 3. For each instance:
# a. Writes the thumbprint as REG_SZ to:
# HKLM\SOFTWARE\Microsoft\Microsoft SQL Server\
# <InstanceKey>\MSSQLServer\SuperSocketNetLib\Certificate
# (confirmed from live SSCM export — this is exactly where SSCM reads it)
# b. Grants Read on the private key to the SQL service account
# c. Restarts the service (only if StartMode = Auto)
# 4. Writes a timestamped audit log
#
# Usage in Certify The Web:
# Managed Certificate > Deployment > Add Deployment Task > Run PowerShell Script
# Script: C:\Scripts\Update-SQLServerCertificate.ps1
#
# KEY REGISTRY FACT (confirmed from live SSCM export):
# Certificate thumbprint lives at:
# HKLM\SOFTWARE\Microsoft\Microsoft SQL Server\<InstanceKey>\
# MSSQLServer\SuperSocketNetLib\Certificate
# NOT under the \Ssl subkey — that path does not exist in a default installation.
# Thumbprint must be lowercase, no spaces, no leading/trailing characters.
# ==============================================================================
param($result)
# --- OPTIONAL CONFIGURATION ---------------------------------------------------
$LogFile = "C:\Logs\SQLCertRenewal.log"
$SkipRestart = $false # Set $true to test without bouncing SQL services
# ------------------------------------------------------------------------------
# ==============================================================================
# LOGGING
# ==============================================================================
function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$entry = "[$timestamp] [$Level] $Message"
Write-Host $entry
$logDir = Split-Path $LogFile -Parent
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
Add-Content -LiteralPath $LogFile -Value $entry
}
# ==============================================================================
# STEP 0 — Validate Certify $result object
# ==============================================================================
Write-Log "============================================================"
Write-Log "SQL Server Certificate Renewal Script Started"
$RawThumbprint = $result.ManagedItem.CertificateThumbprintHash
$PfxPath = $result.ManagedItem.CertificatePath
$PrimaryDomain = $result.ManagedItem.RequestConfig.PrimaryDomain
if (-not $RawThumbprint) {
Write-Log "ERROR: CertificateThumbprintHash is empty. Cannot proceed." -Level "ERROR"
exit 1
}
# Sanitise the thumbprint:
# - Strip everything that is not a hex character (removes spaces, dashes,
# hidden Unicode, zero-width spaces, BOM characters etc.)
# - Force lowercase — confirmed from live SSCM registry export:
# SSCM writes the thumbprint as lowercase, SQL Server loads it correctly.
$NewThumbprint = ($RawThumbprint -replace '[^a-fA-F0-9]', '').ToLower()
Write-Log "Certify Renewal Successful"
Write-Log "Primary Domain : $PrimaryDomain"
Write-Log "PFX Path : $PfxPath"
Write-Log "Raw Thumbprint : $RawThumbprint"
Write-Log "Clean Thumbprint: $NewThumbprint (lowercase, no spaces)"
Write-Log "Previous Thumb : $($result.ManagedItem.CertificatePreviousThumbprintHash)"
# Load the cert from LocalMachine\My — the store comparison is case-insensitive
$cert = Get-ChildItem -Path "Cert:\LocalMachine\My" |
Where-Object { $_.Thumbprint -ieq $NewThumbprint } |
Select-Object -First 1
if (-not $cert) {
Write-Log "ERROR: Certificate '$NewThumbprint' not found in LocalMachine\My store." -Level "ERROR"
exit 1
}
Write-Log "Certificate found: Subject=$($cert.Subject) | Expires=$($cert.NotAfter)"
# Inform if SAN cert — these work fine in SQL Server but may not show in SSCM dropdown
$san = $cert.Extensions | Where-Object { $_.Oid.FriendlyName -eq "Subject Alternative Name" }
if ($san) {
Write-Log "INFO: Certificate has Subject Alternative Names (SAN). SQL Server will use it correctly via registry, but it may not appear in the SSCM certificate dropdown — this is expected." -Level "INFO"
}
# ==============================================================================
# STEP 1 — Discover SQL instances via registry (authoritative source)
# ==============================================================================
Write-Log "------------------------------------------------------------"
Write-Log "Discovering SQL Server instances..."
$regInstanceRoot = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL"
if (-not (Test-Path $regInstanceRoot)) {
Write-Log "ERROR: Registry path not found: $regInstanceRoot" -Level "ERROR"
Write-Log "ERROR: No SQL Server instances found on this machine." -Level "ERROR"
exit 1
}
# Each value under Instance Names\SQL is: InstanceName -> InstanceKey
# e.g. MSSQLSERVER -> MSSQL15.MSSQLSERVER
# CASEMANAGER -> MSSQL15.CASEMANAGER
$instanceMap = @{}
$regProps = Get-ItemProperty -Path $regInstanceRoot
$regProps.PSObject.Properties |
Where-Object { $_.Name -notmatch "^PS" } |
ForEach-Object { $instanceMap[$_.Name] = $_.Value }
if ($instanceMap.Count -eq 0) {
Write-Log "ERROR: No SQL Server instances found in registry." -Level "ERROR"
exit 1
}
Write-Log "Instances discovered ($($instanceMap.Count)): $($instanceMap.Keys -join ', ')"
foreach ($kv in $instanceMap.GetEnumerator()) {
Write-Log " $($kv.Key) -> $($kv.Value)"
}
# ==============================================================================
# HELPERS
# ==============================================================================
function Get-SqlServiceInfo {
param([string]$InstanceName)
# Default instance service name is MSSQLSERVER; named instances are MSSQL$<name>
$serviceName = if ($InstanceName -eq "MSSQLSERVER") { "MSSQLSERVER" } else { "MSSQL`$$InstanceName" }
$svc = Get-WmiObject -Class Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue
if ($svc) {
Write-Log " Service : $serviceName | Account : $($svc.StartName) | StartMode : $($svc.StartMode)"
return @{
ServiceName = $serviceName
ServiceAccount = $svc.StartName
StartMode = $svc.StartMode # "Auto", "Manual", "Disabled"
}
}
Write-Log " WARNING: Service '$serviceName' not found in SCM." -Level "WARN"
return $null
}
function Get-SqlNetLibRegPath {
<#
.SYNOPSIS
Returns the SuperSocketNetLib registry key path for a SQL instance.
This is where SSCM reads and writes the Certificate thumbprint value.
.NOTES
Correct path (confirmed from live SSCM registry export):
HKLM\SOFTWARE\Microsoft\Microsoft SQL Server\<InstanceKey>\
MSSQLServer\SuperSocketNetLib
The Certificate value sits DIRECTLY under SuperSocketNetLib.
There is NO \Ssl subkey in a default SQL Server installation.
#>
param([string]$InstanceKey)
$path = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$InstanceKey\MSSQLServer\SuperSocketNetLib"
if (Test-Path $path) {
Write-Log " Registry : $path"
return $path
}
Write-Log " WARNING: Registry path not found: $path" -Level "WARN"
return $null
}
function Grant-PrivateKeyPermission {
param(
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
[string]$ServiceAccount
)
Write-Log " Granting private key Read to: $ServiceAccount"
# Try CNG key first (used by modern Let's Encrypt certs)
try {
$rsaKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert)
if ($rsaKey -is [System.Security.Cryptography.RSACng]) {
$keyName = $rsaKey.Key.UniqueName
$keyFile = @(
"$env:ProgramData\Microsoft\Crypto\Keys\$keyName",
"$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys\$keyName"
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $keyFile) { throw "CNG key file not found for UniqueName: $keyName" }
Write-Log " CNG key file : $keyFile"
$acl = Get-Acl -Path $keyFile
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($ServiceAccount, "Read", "Allow")
$acl.AddAccessRule($rule)
Set-Acl -Path $keyFile -AclObject $acl
Write-Log " CNG permission granted."
return
}
} catch {
Write-Log " CNG key attempt failed, trying CAPI: $_" -Level "WARN"
}
# Fallback: CAPI key (legacy)
try {
$keyContainer = $Cert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName
$keyPath = "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys\$keyContainer"
if (-not (Test-Path $keyPath)) { throw "CAPI key file not found: $keyPath" }
Write-Log " CAPI key file: $keyPath"
$acl = Get-Acl -Path $keyPath
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($ServiceAccount, "Read", "Allow")
$acl.AddAccessRule($rule)
Set-Acl -Path $keyPath -AclObject $acl
Write-Log " CAPI permission granted."
} catch {
throw "Could not grant private key permission (tried CNG and CAPI): $_"
}
}
# ==============================================================================
# STEP 2 — Process each SQL instance
# ==============================================================================
$successCount = 0
$failCount = 0
foreach ($instanceName in $instanceMap.Keys) {
$instanceKey = $instanceMap[$instanceName]
Write-Log "------------------------------------------------------------"
Write-Log "Processing instance : $instanceName (key: $instanceKey)"
try {
# ---- Service info -------------------------------------------------------
$svcInfo = Get-SqlServiceInfo -InstanceName $instanceName
if (-not $svcInfo) {
Write-Log " Skipping — service not found in SCM." -Level "WARN"
$failCount++
continue
}
# ---- Registry path ------------------------------------------------------
# Path: ...\<InstanceKey>\MSSQLServer\SuperSocketNetLib
# Value: Certificate (directly under SuperSocketNetLib — NOT under \Ssl)
$regPath = Get-SqlNetLibRegPath -InstanceKey $instanceKey
if (-not $regPath) {
Write-Log " Skipping — SuperSocketNetLib registry path not found." -Level "WARN"
$failCount++
continue
}
$oldThumb = (Get-ItemProperty -Path $regPath -Name "Certificate" -ErrorAction SilentlyContinue).Certificate
Write-Log " Current thumbprint : '$oldThumb'"
Write-Log " New thumbprint : '$NewThumbprint'"
# ---- Write thumbprint to registry as REG_SZ -----------------------------
Set-ItemProperty -Path $regPath -Name "Certificate" -Value $NewThumbprint -ErrorAction Stop
Write-Log " Registry write: done"
# Confirm what is now in the registry
$writtenThumb = (Get-ItemProperty -Path $regPath -Name "Certificate" -ErrorAction SilentlyContinue).Certificate
Write-Log " Verified registry : '$writtenThumb'"
if ($writtenThumb -ne $NewThumbprint) {
throw "Registry verify failed — expected '$NewThumbprint' but found '$writtenThumb'"
}
# ---- Private key permission ---------------------------------------------
Grant-PrivateKeyPermission -Cert $cert -ServiceAccount $svcInfo.ServiceAccount
# ---- Service restart (Auto-start services only) -------------------------
if ($SkipRestart) {
Write-Log " SkipRestart=true — skipping service restart." -Level "WARN"
} elseif ($svcInfo.StartMode -ne "Auto") {
Write-Log " Skipping restart — StartMode is '$($svcInfo.StartMode)' (only Auto services are restarted)." -Level "WARN"
} else {
Write-Log " Stopping : $($svcInfo.ServiceName)"
Stop-Service -Name $svcInfo.ServiceName -Force -ErrorAction Stop
Start-Sleep -Seconds 3
Write-Log " Starting : $($svcInfo.ServiceName)"
Start-Service -Name $svcInfo.ServiceName -ErrorAction Stop
Start-Sleep -Seconds 5
$status = (Get-Service -Name $svcInfo.ServiceName).Status
if ($status -ne "Running") {
throw "Service did not reach Running state after restart. Status: $status"
}
Write-Log " Service running." -Level "OK"
}
Write-Log " Instance '$instanceName' complete." -Level "OK"
$successCount++
} catch {
Write-Log " ERROR processing '$instanceName': $_" -Level "ERROR"
# Pull recent SQL errors from Application event log to aid diagnosis
try {
$events = Get-EventLog -LogName Application -Source "MSSQL*" -Newest 3 -ErrorAction SilentlyContinue |
Where-Object { $_.EntryType -eq "Error" }
if ($events) {
Write-Log " Recent SQL Application event errors:" -Level "ERROR"
$events | ForEach-Object {
Write-Log " [$($_.TimeGenerated)] $($_.Message.Split("`n")[0])" -Level "ERROR"
}
}
} catch { }
$failCount++
}
}
# ==============================================================================
# SUMMARY
# ==============================================================================
Write-Log "------------------------------------------------------------"
Write-Log "Summary : Success=$successCount | Failed=$failCount"
Write-Log "Thumbprint : $NewThumbprint"
Write-Log "Cert expiry : $($cert.NotAfter)"
Write-Log "============================================================"
exit $(if ($failCount -gt 0) { 1 } else { 0 })