############################################################################### #### windows-pki-smoke-tests.ps1 — Verify Windows PKI infrastructure #### #### Checks CA service, certificates, CRL, OCSP, templates, #### #### auto-enrollment, pending requests, AIA/CDP extensions. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: .\windows-pki-smoke-tests.ps1 #### #### .\windows-pki-smoke-tests.ps1 -CAServer CA01 #### #### .\windows-pki-smoke-tests.ps1 -OutputFormat tap #### #### #### #### See -Help for all options. #### ############################################################################### [CmdletBinding()] param( [string]$CAName = "", [string]$CAServer = "", [int]$CRLThresholdHours = 24, [int]$PendingThreshold = 10, [ValidateSet("text","tap")] [string]$OutputFormat = "text", [switch]$NoColor, [switch]$Help ) $ErrorActionPreference = "Continue" # ============================================================================ # HELP # ============================================================================ if ($Help) { @" Usage: .\windows-pki-smoke-tests.ps1 [OPTIONS] Smoke-test Windows PKI infrastructure. PowerShell 5.1+. Designed for domain-joined Windows machines with AD CS. Parameters: -CAName NAME CA common name (default: auto-detect via certutil) -CAServer SERVER CA server hostname or IP (default: auto-detect via certutil) -CRLThresholdHours N Warn if CRL expires within N hours (default: 24) -PendingThreshold N Warn if pending requests exceed N (default: 10) -OutputFormat FORMAT Output: text (default), tap -NoColor Disable coloured output -Verbose Show debug output -Help Show this help Examples: .\windows-pki-smoke-tests.ps1 .\windows-pki-smoke-tests.ps1 -CAServer CA01.corp.local .\windows-pki-smoke-tests.ps1 -CAName "corp-CA01-CA" -CAServer CA01 .\windows-pki-smoke-tests.ps1 -CRLThresholdHours 48 -OutputFormat tap .\windows-pki-smoke-tests.ps1 -NoColor -Verbose "@ exit 0 } # ============================================================================ # STATE # ============================================================================ $script:Pass = 0 $script:Fail = 0 $script:Skip = 0 $script:Total = 0 $script:Results = @() $script:StartTime = Get-Date # ============================================================================ # COLORS # ============================================================================ function Write-Color { param([string]$Text, [string]$Color = "White") if ($NoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color } } function Write-Log { param([string]$Msg) Write-Color "[INFO] $Msg" "Cyan" } function Write-Warn { param([string]$Msg) Write-Color "[WARN] $Msg" "Yellow" } function Write-Err { param([string]$Msg) Write-Color "[ERROR] $Msg" "Red" } # ============================================================================ # TEST RESULT RECORDING # ============================================================================ function Record-Pass { param([string]$Name, [string]$Detail = "") $script:Pass++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="PASS"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name$(if($Detail){" ($Detail)"})" } else { $mark = if ($NoColor) { "[PASS]" } else { [char]0x2713 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Green" } } function Record-Fail { param([string]$Name, [string]$Detail = "") $script:Fail++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="FAIL"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "not ok $($script:Total) - $Name" if ($Detail) { Write-Host " # $Detail" } } else { $mark = if ($NoColor) { "[FAIL]" } else { [char]0x2717 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Red" } } function Record-Skip { param([string]$Name, [string]$Reason = "") $script:Skip++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="SKIP"; Name=$Name; Detail=$Reason } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name # SKIP $Reason" } else { $mark = if ($NoColor) { "[SKIP]" } else { [char]0x2298 } $msg = " $mark $Name" if ($Reason) { $msg += " - $Reason" } Write-Color $msg "Yellow" } } # ============================================================================ # HELPERS # ============================================================================ function Test-CommandExists { param([string]$Command) $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) } function Write-Section { param([string]$Name) if ($OutputFormat -eq "text") { Write-Host "" Write-Color $Name "White" } } # ============================================================================ # AUTO-DETECTION # ============================================================================ function Resolve-CAServer { if ($CAServer) { Write-Verbose "Using specified CA server: $CAServer" return $CAServer } if (-not (Test-CommandExists "certutil")) { Write-Err "certutil not available. Use -CAServer parameter." exit 1 } try { $output = certutil -config - -ping 2>&1 | Out-String if ($output -match "server\s*[`"``]([^`"``]+)[`"``]") { Write-Verbose "Auto-detected CA server: $($Matches[1].Trim())" return $Matches[1].Trim() } } catch {} try { $output = certutil 2>&1 | Out-String if ($output -match "Config:\s*`"?([^\\`"]+)\\") { Write-Verbose "Auto-detected CA server from certutil: $($Matches[1].Trim())" return $Matches[1].Trim() } } catch {} Write-Err "Cannot auto-detect CA server. Use -CAServer parameter." exit 1 } function Resolve-CAName { if ($CAName) { Write-Verbose "Using specified CA name: $CAName" return $CAName } if (-not (Test-CommandExists "certutil")) { Write-Err "certutil not available. Use -CAName parameter." exit 1 } try { $output = certutil 2>&1 | Out-String if ($output -match "Name:\s*[`"``]?([^`"``\r\n]+)[`"``]?") { Write-Verbose "Auto-detected CA name: $($Matches[1].Trim())" return $Matches[1].Trim() } } catch {} try { $output = certutil -config - -ping 2>&1 | Out-String if ($output -match "CA\s*[`"``]([^`"``]+)[`"``]") { Write-Verbose "Auto-detected CA name from config: $($Matches[1].Trim())" return $Matches[1].Trim() } } catch {} Write-Err "Cannot auto-detect CA name. Use -CAName parameter." exit 1 } # ============================================================================ # TESTS # ============================================================================ # -- 1. CA Service ----------------------------------------------------------- function Test-CAService { Write-Section "CA Service" try { $svc = Get-Service -Name "CertSvc" -ComputerName $script:CAServerName -ErrorAction Stop if ($svc.Status -eq "Running") { Record-Pass "CA service status" "CertSvc running on $($script:CAServerName)" } else { Record-Fail "CA service status" "CertSvc is $($svc.Status) on $($script:CAServerName)" } } catch { try { $svc = Get-Service -Name "CertSvc" -ErrorAction Stop if ($svc.Status -eq "Running") { Record-Pass "CA service status" "CertSvc running (local)" } else { Record-Fail "CA service status" "CertSvc is $($svc.Status)" } } catch { Record-Fail "CA service status" "cannot query CertSvc - $($_.Exception.Message)" } } } # -- 2. CA Certificate Validity --------------------------------------------- function Test-CACertificate { Write-Section "CA Certificate" if (-not (Test-CommandExists "certutil")) { Record-Skip "CA certificate validity" "certutil not available" return } try { $configString = "$($script:CAServerName)\$($script:CACommonName)" $output = certutil -config $configString -ca.cert 2>&1 | Out-String if (-not ($output -match "NotAfter:\s*(.+)")) { $output = certutil -ca.cert 2>&1 | Out-String } if ($output -match "NotAfter:\s*(.+)") { $notAfter = [DateTime]::Parse($Matches[1].Trim()) $daysLeft = ($notAfter - (Get-Date)).Days $expiryStr = $notAfter.ToString("yyyy-MM-dd") if ($daysLeft -lt 0) { Record-Fail "CA certificate validity" "CA certificate expired on $expiryStr" } elseif ($daysLeft -le 30) { Record-Fail "CA certificate validity" "CA certificate expires in $daysLeft days ($expiryStr)" } else { Record-Pass "CA certificate validity" "expires $expiryStr ($daysLeft days remaining)" } } else { Record-Fail "CA certificate validity" "could not determine CA certificate expiry" } } catch { Record-Fail "CA certificate validity" "certutil error - $($_.Exception.Message)" } } # -- 3. CRL Publication ------------------------------------------------------ function Test-CRLPublication { Write-Section "CRL" if (-not (Test-CommandExists "certutil")) { Record-Skip "CRL publication" "certutil not available" return } try { $configString = "$($script:CAServerName)\$($script:CACommonName)" $output = certutil -config $configString -crl 2>&1 | Out-String if ($output -match "CRL published" -or $output -match "command completed successfully") { $crlOutput = certutil -config $configString -getcrl 2>&1 | Out-String if (-not ($crlOutput -match "Next Update:\s*(.+)")) { $crlOutput = certutil -getcrl 2>&1 | Out-String } if ($crlOutput -match "Next Update:\s*(.+)") { $nextUpdate = [DateTime]::Parse($Matches[1].Trim()) $hoursLeft = ($nextUpdate - (Get-Date)).TotalHours $hoursStr = [math]::Floor($hoursLeft) if ($hoursLeft -lt 0) { Record-Fail "CRL publication" "CRL has expired (next update was $($nextUpdate.ToString('yyyy-MM-dd HH:mm')))" } elseif ($hoursLeft -le $CRLThresholdHours) { Record-Fail "CRL publication" "CRL expires in ${hoursStr}h (threshold: ${CRLThresholdHours}h)" } else { Record-Pass "CRL publication" "next update in ${hoursStr}h" } } else { Record-Pass "CRL publication" "CRL published successfully" } } elseif ($output -match "error|fail") { Record-Fail "CRL publication" "certutil -crl reported an error" } else { Record-Fail "CRL publication" "could not verify CRL publication" } } catch { Record-Fail "CRL publication" "certutil error - $($_.Exception.Message)" } } # -- 4. OCSP Responder ------------------------------------------------------ function Test-OCSPResponder { Write-Section "OCSP" if (-not (Test-CommandExists "certutil")) { Record-Skip "OCSP responder" "certutil not available" return } try { $configString = "$($script:CAServerName)\$($script:CACommonName)" $output = certutil -config $configString -ca.cert 2>&1 | Out-String if (-not ($output -match "URL=(\S*ocsp\S*)")) { $output = certutil -ca.cert 2>&1 | Out-String } if ($output -match "URL=(\S*ocsp\S*)") { $ocspUrl = $Matches[1].Trim() Write-Verbose "Testing OCSP URL: $ocspUrl" try { $response = Invoke-WebRequest -Uri $ocspUrl -Method GET -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400) { Record-Pass "OCSP responder" $ocspUrl } else { Record-Fail "OCSP responder" "$ocspUrl - HTTP $($response.StatusCode)" } } catch { $statusCode = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { $null } if ($statusCode -and $statusCode -lt 500) { Record-Pass "OCSP responder" "$ocspUrl (HTTP $statusCode)" } else { Record-Fail "OCSP responder" "$ocspUrl - $($_.Exception.Message)" } } } else { Record-Skip "OCSP responder" "OCSP URL not configured in CA certificate" } } catch { Record-Fail "OCSP responder" "error - $($_.Exception.Message)" } } # -- 5. Certificate Templates ----------------------------------------------- function Test-CertificateTemplates { Write-Section "Templates" if (-not (Test-CommandExists "certutil")) { Record-Skip "Certificate templates" "certutil not available" return } try { $configString = "$($script:CAServerName)\$($script:CACommonName)" $output = certutil -config $configString -catemplates 2>&1 | Out-String if ($output -match "error|fail" -and $output -notmatch "command completed") { $output = certutil -catemplates 2>&1 | Out-String } if ($output -match "command completed successfully" -or $output -match ":") { $templateLines = ($output -split "`n") | Where-Object { $_ -match ":" -and $_ -notmatch "command completed" -and $_ -notmatch "CertUtil" -and $_ -notmatch "^\s*$" } $templateCount = ($templateLines | Measure-Object).Count if ($templateCount -gt 0) { Record-Pass "Certificate templates" "$templateCount template(s) published" } else { Record-Fail "Certificate templates" "no templates found" } } else { Record-Fail "Certificate templates" "certutil -catemplates failed" } } catch { Record-Fail "Certificate templates" "certutil error - $($_.Exception.Message)" } } # -- 6. Auto-Enrollment ------------------------------------------------------ function Test-AutoEnrollment { Write-Section "Auto-Enrollment" try { $regPath = "HKLM:\SOFTWARE\Policies\Microsoft\Cryptography\AutoEnrollment" if (-not (Test-Path $regPath)) { Record-Skip "Auto-enrollment configured" "registry key not found (GPO may not be applied)" return } $flags = (Get-ItemProperty -Path $regPath -Name "AEPolicy" -ErrorAction SilentlyContinue).AEPolicy if ($null -eq $flags) { Record-Skip "Auto-enrollment configured" "AEPolicy value not set" return } # AEPolicy flags: 1 = enroll, 2 = update, 4 = renew expired if ($flags -band 1) { $features = @() if ($flags -band 1) { $features += "enroll" } if ($flags -band 2) { $features += "update" } if ($flags -band 4) { $features += "renew expired" } $featureStr = $features -join ", " Record-Pass "Auto-enrollment configured" "enabled ($featureStr)" } else { Record-Fail "Auto-enrollment configured" "AEPolicy is $flags (enrollment not enabled)" } } catch { Record-Fail "Auto-enrollment configured" "registry error - $($_.Exception.Message)" } } # -- 7. Pending Requests ---------------------------------------------------- function Test-PendingRequests { Write-Section "Pending Requests" if (-not (Test-CommandExists "certutil")) { Record-Skip "Pending requests" "certutil not available" return } try { $configString = "$($script:CAServerName)\$($script:CACommonName)" $output = certutil -config $configString -view -restrict "disposition=9" -out "requestid" 2>&1 | Out-String $pendingCount = 0 if ($output -match "(\d+) Row\(s\)") { $pendingCount = [int]$Matches[1] } elseif ($output -match "0 Rows") { $pendingCount = 0 } if ($pendingCount -gt $PendingThreshold) { Record-Fail "Pending requests" "$pendingCount pending (threshold: $PendingThreshold)" } else { Record-Pass "Pending requests" "$pendingCount pending (threshold: $PendingThreshold)" } } catch { Record-Fail "Pending requests" "certutil error - $($_.Exception.Message)" } } # -- 8. AIA Extension ------------------------------------------------------- function Test-AIAExtension { Write-Section "Extensions" if (-not (Test-CommandExists "certutil")) { Record-Skip "AIA extension configured" "certutil not available" return } try { $configString = "$($script:CAServerName)\$($script:CACommonName)" $output = certutil -config $configString -ca.cert 2>&1 | Out-String if (-not ($output -match "Authority Information Access")) { $output = certutil -ca.cert 2>&1 | Out-String } if ($output -match "Authority Information Access") { $urlCount = ([regex]::Matches($output, "URL=\S+")).Count if ($urlCount -gt 0) { Record-Pass "AIA extension configured" "$urlCount URL(s) configured" } else { Record-Pass "AIA extension configured" "AIA extension present" } } else { Record-Fail "AIA extension configured" "AIA extension not found in CA certificate" } } catch { Record-Fail "AIA extension configured" "certutil error - $($_.Exception.Message)" } } # -- 9. CDP Extension ------------------------------------------------------- function Test-CDPExtension { if (-not (Test-CommandExists "certutil")) { Record-Skip "CDP extension configured" "certutil not available" return } try { $configString = "$($script:CAServerName)\$($script:CACommonName)" $output = certutil -config $configString -ca.cert 2>&1 | Out-String if (-not ($output -match "CRL Distribution Point")) { $output = certutil -ca.cert 2>&1 | Out-String } if ($output -match "CRL Distribution Point") { $urlCount = ([regex]::Matches($output, "URL=\S+")).Count if ($urlCount -gt 0) { Record-Pass "CDP extension configured" "$urlCount URL(s) configured" } else { Record-Pass "CDP extension configured" "CDP extension present" } } else { Record-Fail "CDP extension configured" "CDP extension not found in CA certificate" } } catch { Record-Fail "CDP extension configured" "certutil error - $($_.Exception.Message)" } } # -- 10. Enterprise CA Registration ----------------------------------------- function Test-EnterpriseCARegistration { Write-Section "AD Registration" if (-not (Test-CommandExists "certutil")) { Record-Skip "Enterprise CA registration" "certutil not available" return } try { $output = certutil -dcsinfo 2>&1 | Out-String if ($output -match $script:CACommonName -or $output -match "Enrollment Services") { Record-Pass "Enterprise CA registration" "CA registered in AD" return } } catch { Write-Verbose "certutil -dcsinfo failed: $($_.Exception.Message)" } try { $output = certutil -config "$($script:CAServerName)\$($script:CACommonName)" -ping 2>&1 | Out-String if ($output -match "interface is alive" -or $output -match "command completed successfully") { Record-Pass "Enterprise CA registration" "CA reachable and responsive" return } } catch { Write-Verbose "certutil -ping failed: $($_.Exception.Message)" } try { $rootDSE = [ADSI]"LDAP://RootDSE" $configNC = $rootDSE.configurationNamingContext $enrollDN = "LDAP://CN=Enrollment Services,CN=Public Key Services,CN=Services,$configNC" $enrollContainer = [ADSI]$enrollDN $found = $false foreach ($child in $enrollContainer.Children) { if ($child.cn -eq $script:CACommonName -or $child.cn -match $script:CACommonName) { $found = $true break } } if ($found) { Record-Pass "Enterprise CA registration" "CA found in AD Enrollment Services" } else { Record-Fail "Enterprise CA registration" "CA not found in AD Enrollment Services" } } catch { Record-Fail "Enterprise CA registration" "cannot verify AD registration - $($_.Exception.Message)" } } # ============================================================================ # OUTPUT # ============================================================================ function Write-Header { if ($OutputFormat -eq "tap") { Write-Host "TAP version 13" } else { Write-Host "" Write-Color "Windows PKI Smoke Tests" "White" Write-Host "CA Server: $($script:CAServerName)" Write-Host "CA Name: $($script:CACommonName)" Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')" } } function Write-Summary { $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds) if ($OutputFormat -eq "tap") { Write-Host "1..$($script:Total)" Write-Host "# pass $($script:Pass)" Write-Host "# fail $($script:Fail)" Write-Host "# skip $($script:Skip)" } else { Write-Host "" $separator = [string]::new([char]0x2500, 40) Write-Color $separator "White" Write-Color "Summary $($script:CAServerName)\$($script:CACommonName)" "White" $summaryLine = " $($script:Pass) passed $($script:Fail) failed $($script:Skip) skipped (${duration}s)" Write-Host $summaryLine Write-Color $separator "White" if ($script:Fail -eq 0) { Write-Color "All tests passed." "Green" } else { Write-Color "$($script:Fail) test(s) failed." "Red" } } } # ============================================================================ # MAIN # ============================================================================ # Resolve targets $script:CAServerName = Resolve-CAServer $script:CACommonName = Resolve-CAName Write-Header # Run all tests Test-CAService Test-CACertificate Test-CRLPublication Test-OCSPResponder Test-CertificateTemplates Test-AutoEnrollment Test-PendingRequests Test-AIAExtension Test-CDPExtension Test-EnterpriseCARegistration Write-Summary if ($script:Fail -eq 0) { exit 0 } else { exit 1 }