Private/CAOcsp.ps1
|
<#
.SYNOPSIS CA Manager - Online Responder (OCSP) install + one revocation configuration for the local single-tier CA (dashboard menu option 4). Requires Modules\CACore.ps1 (Invoke-CAStep) and, to apply, the AD CS Online Responder role binaries + the ADCSDeployment module. Run on the CA box. .DESCRIPTION Get-CAOcspPlan is PURE - it turns the CA identity + CA_TemplateOcspSigning answer into the exact HKLM\...\OCSPSvc\Responder\<name> (+ \Provider) registry shape, transcribed from a real client's live responder (a captured CA inventory, section C): HashAlgorithmId=SHA256, ProviderCLSID={4956d17f-88fd-4198-b287-1e6e65883b19} (MS CRL-based), SigningFlags=861, ReminderDuration=90, Provider\RefreshTimeOut=300000. Every constant here is a captured fact - a wrong value is a one-line data fix. Install-CAOcspRole / New-CAOcspRevocationConfig / Confirm-CAOcspSigningCertificate are the engines - every mutation via Invoke-CAStep (dry-run aware). The revocation config is written by DIRECT REGISTRY writes (deterministic, previewable, testable, and exactly the reference-client shape); New-CAOcspRevocationConfigViaCom is a documented CertAdm.OCSPAdmin fallback, only used when New-CAOcspRevocationConfig -UseCom is passed. The responder auto-enrolls its short-lived signing cert from NSP-OCSPResponseSigning once OCSPSvc restarts (SigningFlags 861 includes AUTOENROLL/AUTODISCOVER; menu 4 already granted the responder host / its group Enroll on that template, and menu 15 - the enrollment gate - needs its AutoEnroll flipped on too). Confirm-CAOcspSigningCertificate nudges that along and is NON-FATAL if it hasn't landed yet. #> $script:CAOcspResponderKey = 'HKLM:\SYSTEM\CurrentControlSet\Services\OCSPSvc\Responder' $script:CAOcspProviderClsid = '{4956d17f-88fd-4198-b287-1e6e65883b19}' # Microsoft CRL-based revocation provider # ========================================================================= # PURE # ========================================================================= function Get-CAOcspPlan { <# .SYNOPSIS PURE. The Online Responder revocation-config plan for the local CA. No I/O. .PARAMETER CACommonName The CA common name ($Status.CACommonName). .PARAMETER CAMachineFqdn The CA/responder box FQDN. .PARAMETER CASanitizedName The CA sanitized name (== %3 in the CRL filename). .PARAMETER ConfigNC Configuration naming context, for the LDAP CDP URL; omit -> no ldap entry. #> param( $CAAnswers, [Parameter(Mandatory)][string]$CACommonName, [Parameter(Mandatory)][string]$CAMachineFqdn, [Parameter(Mandatory)][string]$CASanitizedName, [string]$ConfigNC, [bool]$DeltaCrl = $false, [bool]$ResponderColocated = $true, [string]$WindowsDir = $env:windir, # IIS request-filtering limits for the /ocsp app - base64-encoded OCSP GET tokens can get long # enough to exceed IIS's defaults (maxUrl 4096B / maxQueryString 2048B -> HTTP 404.14 / 404.15). # Defaults match what's been applied by hand in the field. [int]$OcspMaxUrlBytes = 16384, [int]$OcspMaxQueryStringBytes = 8192 ) # signing template - reuse the menu-4 collision guard (blank / literal 'OCSPResponseSigning' -> NSP-) $tmpl = if ($CAAnswers -and $CAAnswers.CA_TemplateOcspSigning) { "$($CAAnswers.CA_TemplateOcspSigning)" } else { '' } if ([string]::IsNullOrWhiteSpace($tmpl) -or $tmpl -eq 'OCSPResponseSigning') { $tmpl = 'NSP-OCSPResponseSigning' } # SigningCertificateTemplate in the registry is the template's CN (its AD 'name'), NOT the # displayName - the responder passes it straight to the auto-enroll API. Menu 4 / New-CAVpnTemplate # builds the CN by stripping non-alphanumerics from the display name, and the OCSP MMC wizard # writes the same CN form: 'NSP-OCSPResponseSigning' -> 'NSPOCSPResponseSigning'. $tmpl = ($tmpl -replace '[^A-Za-z0-9]', '') # The CRL-based provider does GETs - it needs real URLs (http:// or ldap://), NOT a bare # C:\...\CertEnroll\x.crl filesystem path (which it silently ignores -> no CRL loaded -> the # responder can't build a response -> HTTP 500 on /ocsp). Primary is the CA's OWN IIS # /CertEnroll/ vdir (menu 6) over http on this box - a real URL, served locally, no internet # round-trip. LDAP as backup. The App Proxy URL is added when known (matches a real client's live # responder, and covers the case where local IIS isn't up). $hostShort = ($CAMachineFqdn -split '\.')[0] $baseCrl = New-Object System.Collections.Generic.List[string] # localhost first when the responder is co-located on the CA (our only supported topology - menu 9 # installs the role on the CA box): same local IIS /CertEnroll/ vdir as the FQDN entry but with no # DNS lookup, no host-header/binding dependency, no name-mismatch risk. The FQDN / LDAP / App Proxy # entries stay as ordered fallbacks. A split/dedicated responder box has no /CertEnroll vdir, so # this is gated on -ResponderColocated. if ($ResponderColocated) { $baseCrl.Add("http://localhost/CertEnroll/$CASanitizedName.crl") } $baseCrl.Add("http://$CAMachineFqdn/CertEnroll/$CASanitizedName.crl") if (-not [string]::IsNullOrWhiteSpace($ConfigNC)) { # ${ConfigNC} braces required - PS7 eats "$ConfigNC?certificateRevocationList..." as a # null-conditional member chain otherwise. $baseCrl.Add("ldap:///CN=$CASanitizedName,CN=$hostShort,CN=CDP,CN=Public Key Services,CN=Services,${ConfigNC}?certificateRevocationList?base?objectClass=cRLDistributionPoint") } $appProxyCrl = if ($CAAnswers -and "$($CAAnswers.CA_AppProxyCrlFqdn)" -match '\.') { "$($CAAnswers.CA_AppProxyCrlFqdn)".Trim() } else { $null } if ($appProxyCrl) { $baseCrl.Add("http://$appProxyCrl/CertEnroll/$CASanitizedName.crl") } $deltaCrlUrls = @() if ($DeltaCrl) { $deltaCrlUrls = New-Object System.Collections.Generic.List[string] if ($ResponderColocated) { $deltaCrlUrls.Add("http://localhost/CertEnroll/$CASanitizedName+.crl") } $deltaCrlUrls.Add("http://$CAMachineFqdn/CertEnroll/$CASanitizedName+.crl") if ($appProxyCrl) { $deltaCrlUrls.Add("http://$appProxyCrl/CertEnroll/$CASanitizedName+.crl") } $deltaCrlUrls = @($deltaCrlUrls) } $configName = $CACommonName $caConfig = "$CAMachineFqdn\$CACommonName" $ck = "Responder\$configName" $pk = "Responder\$configName\Provider" $regValues = @( [pscustomobject]@{ KeyPath = $ck; Name = 'CACertificate'; Type = 'Binary'; Value = '<PLACEHOLDER - DER of the CA cert, filled by the engine>' } [pscustomobject]@{ KeyPath = $ck; Name = 'CAConfig'; Type = 'String'; Value = $caConfig } [pscustomobject]@{ KeyPath = $ck; Name = 'HashAlgorithmId'; Type = 'String'; Value = 'SHA256' } [pscustomobject]@{ KeyPath = $ck; Name = 'ProviderCLSID'; Type = 'String'; Value = $script:CAOcspProviderClsid } [pscustomobject]@{ KeyPath = $ck; Name = 'ReminderDuration'; Type = 'DWord'; Value = 90 } [pscustomobject]@{ KeyPath = $ck; Name = 'SigningCertificateTemplate'; Type = 'String'; Value = $tmpl } [pscustomobject]@{ KeyPath = $ck; Name = 'SigningFlags'; Type = 'DWord'; Value = 861 } [pscustomobject]@{ KeyPath = $pk; Name = 'BaseCrlUrls'; Type = 'MultiString'; Value = @($baseCrl) } [pscustomobject]@{ KeyPath = $pk; Name = 'DeltaCrlUrls'; Type = 'MultiString'; Value = $deltaCrlUrls } [pscustomobject]@{ KeyPath = $pk; Name = 'RefreshTimeOut'; Type = 'DWord'; Value = 300000 } ) [pscustomobject]@{ ConfigName = $configName CAConfig = $caConfig CACommonName = $CACommonName HashAlgorithmId = 'SHA256' ProviderCLSID = $script:CAOcspProviderClsid SigningFlags = 861 ReminderDuration = 90 RefreshTimeOut = 300000 SigningCertificateTemplate = $tmpl BaseCrlUrls = @($baseCrl) DeltaCrlUrls = $deltaCrlUrls ResponderFqdn = $CAMachineFqdn ResponderUrl = "http://$CAMachineFqdn/ocsp" LocalVerifyUrl = 'http://localhost/ocsp' RegistryValues = $regValues # Online Responder auditing (ocsp.msc -> server node -> Properties -> Audit tab), stored as a # bitmask at the RESPONDER ROOT key (script:CAOcspResponderKey), not per-config: # 1 = Start/Stop the Online Responder Service, 2 = Configuration changes, # 4 = Requests submitted (per-query - deliberately excluded, floods the log), # 8 = Security settings changes. 1+2+8 = 11. # The registry bit alone does nothing without the OS-level "Certification Services" audit # subcategory also enabled (same requirement as the CA's own CA\AuditFilter). AuditFilterValue = 11 AuditFilterOsSubcategory = 'Certification Services' OcspMaxUrlBytes = $OcspMaxUrlBytes OcspMaxQueryStringBytes = $OcspMaxQueryStringBytes } } # --------------------------------------------------------------------------- function Show-CAOcspPlan { param($Plan) Write-Host "" Write-Host " Online Responder role: ensure ADCS-Online-Cert + Install-AdcsOnlineResponder (creates /ocsp, starts OCSPSvc)" -ForegroundColor White Write-Host "" Write-Host (" Revocation config: {0}" -f $Plan.ConfigName) -ForegroundColor White Write-Host (" CAConfig : {0}" -f $Plan.CAConfig) -ForegroundColor Gray Write-Host (" HashAlgorithmId : {0}" -f $Plan.HashAlgorithmId) -ForegroundColor Gray Write-Host " SigningFlags : 861 (0x35D = SILENT + RESPONDER_ID_KEYHASH + AUTODISCOVER + ALLOW_AUTORENEWAL + FORCE_ISSUER_ISCA + AUTOENROLL + ALLOW_NONCE)" -ForegroundColor Gray Write-Host (" SigningCertificateTemplate : {0}" -f $Plan.SigningCertificateTemplate) -ForegroundColor Gray Write-Host (" ProviderCLSID : {0} (Microsoft CRL-based revocation provider)" -f $Plan.ProviderCLSID) -ForegroundColor Gray Write-Host " ReminderDuration : 90" -ForegroundColor Gray Write-Host (" RefreshTimeOut : {0} ms" -f $Plan.RefreshTimeOut) -ForegroundColor Gray Write-Host "" Write-Host " CRL sources (Provider\BaseCrlUrls):" -ForegroundColor Gray foreach ($u in $Plan.BaseCrlUrls) { Write-Host " $u" -ForegroundColor DarkGray } if ($Plan.DeltaCrlUrls) { foreach ($u in $Plan.DeltaCrlUrls) { Write-Host " (delta) $u" -ForegroundColor DarkGray } } Write-Host "" Write-Host (" Registry: {0} value(s) under {1}\{2}" -f $Plan.RegistryValues.Count, $script:CAOcspResponderKey, $Plan.ConfigName) -ForegroundColor DarkGray foreach ($r in $Plan.RegistryValues) { $shown = if ($r.Name -eq 'CACertificate') { "<byte[] DER of CN=$($Plan.CACommonName)>" } elseif ($r.Value -is [array]) { '@(' + (($r.Value) -join '; ') + ')' } else { "$($r.Value)" } $where = if ($r.KeyPath -match '\\Provider$') { 'Provider\' } else { '' } Write-Host (" [{0,-11}] {1}{2} = {3}" -f $r.Type, $where, $r.Name, $shown) -ForegroundColor DarkGray } Write-Host "" Write-Host (" The responder auto-enrolls '{0}' as {1}\{2}`$ once OCSPSvc restarts (menu 4 granted" -f ` $Plan.SigningCertificateTemplate, $env:USERDOMAIN, (($Plan.ResponderFqdn -split '\.')[0])) -ForegroundColor DarkGray Write-Host " that account Enroll; flip AutoEnroll on for it at menu 15, the enrollment gate, if you haven't already)." -ForegroundColor DarkGray Write-Host (" Verify: {0} (locally: {1})" -f $Plan.ResponderUrl, $Plan.LocalVerifyUrl) -ForegroundColor DarkGray Write-Host "" Write-Host " Hardening:" -ForegroundColor White Write-Host (" Auditing : {0}\AuditFilter = {1} (Start/Stop + Config + Security changes; OS subcategory '{2}')" -f $script:CAOcspResponderKey, $Plan.AuditFilterValue, $Plan.AuditFilterOsSubcategory) -ForegroundColor Gray Write-Host (" IIS /ocsp : maxUrl {0} bytes, maxQueryString {1} bytes (avoids 404.14/404.15 on long base64 OCSP GET tokens)" -f $Plan.OcspMaxUrlBytes, $Plan.OcspMaxQueryStringBytes) -ForegroundColor Gray } # --------------------------------------------------------------------------- function Get-CAOcspCACertBytes { <# .SYNOPSIS The DER bytes of the CA certificate for CN=<CACommonName> from LocalMachine\CA or \My (same selector Get-CAStatus uses), or $null. Fallback: certutil -ca.cert. #> param([Parameter(Mandatory)][string]$CACommonName) try { $pat = [regex]::Escape("CN=$CACommonName") $c = Get-ChildItem Cert:\LocalMachine\CA, Cert:\LocalMachine\My -ErrorAction SilentlyContinue | Where-Object { $_.Subject -match $pat -and ($_.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' } | ForEach-Object { $_.CertificateAuthority }) -contains $true } | Sort-Object NotAfter -Descending | Select-Object -First 1 if ($c) { return [byte[]]$c.RawData } } catch { } try { $tmp = Join-Path $env:TEMP ("_caocsp_{0}.cer" -f ([guid]::NewGuid().ToString('N'))) & certutil.exe -ca.cert $tmp 2>&1 | Out-Null if (Test-Path $tmp) { $b = [System.IO.File]::ReadAllBytes($tmp); Remove-Item $tmp -ErrorAction SilentlyContinue; if ($b.Length) { return [byte[]]$b } } } catch { } return $null } # --------------------------------------------------------------------------- function Test-CAOcspEndpoint { <# .SYNOPSIS [bool], never throws. Bare mode: a GET to /ocsp answering 200/400/405 == the ISAPI handler is alive (404 = app missing, refused = IIS down). -IssuedCertPath mode: certutil -verify -urlfetch on a real issued cert. Bare mode RETRIES: for ~15s the ISAPI returns HTTP 500 with win32 0x80004005 (E_FAIL) while OCSPSvc is mid-reload just after a service bounce / app-pool recycle - that is transient, not "dead", so a single probe in that window is misleading. #> param( [string]$Url = 'http://localhost/ocsp', [string]$IssuedCertPath, [string]$IssuerCertPath, [int]$RetrySeconds = 20 ) if ($IssuedCertPath) { try { $cuArgs = @('-verify', '-urlfetch', $IssuedCertPath) if ($IssuerCertPath) { $cuArgs += $IssuerCertPath } $out = & certutil.exe @cuArgs 2>&1 | Out-String return ($LASTEXITCODE -eq 0 -and $out -match '(?i)(Verified "OCSP"|OCSP.*(verified|passed|success)|revocation check passed)') } catch { return $false } } $deadline = (Get-Date).AddSeconds([Math]::Max(0, $RetrySeconds)) do { $alive = $null # $true = answered, $false = definitively dead (404 / refused), $null = retryable try { $resp = Invoke-WebRequest -UseBasicParsing -Method GET -Uri $Url -TimeoutSec 10 -ErrorAction Stop $alive = ($resp.StatusCode -eq 200) } catch [System.Net.WebException] { $sc = try { [int]$_.Exception.Response.StatusCode } catch { 0 } if ($sc -eq 200 -or $sc -eq 400 -or $sc -eq 405) { $alive = $true } elseif ($sc -eq 404 -or $sc -eq 0) { $alive = $false } # app missing / connection refused else { $alive = $null } # 500/503/... - transient reload } catch { $alive = $null } if ($null -ne $alive) { return $alive } Start-Sleep -Seconds 3 } while ((Get-Date) -lt $deadline) return $false } # --------------------------------------------------------------------------- function Test-CAOcspWebApp { <# .SYNOPSIS [bool], read-only. Is the Online Responder's /ocsp IIS application present? The role's feature (ADCS-Online-Cert) and its service (OCSPSvc) can be up while the /ocsp web app is absent - it is created when the responder role is configured, and is lost if IIS is (re)installed afterwards. appcmd.exe ships with Web-Server; fall back to an HTTP probe (404 == missing). #> $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe' if (Test-Path $appcmd) { try { $out = & $appcmd list app 2>$null | Out-String if ($out -match '(?im)"[^"]*/ocsp"') { return $true } if ($LASTEXITCODE -eq 0 -and $out) { return $false } } catch { } } try { Invoke-WebRequest -UseBasicParsing -Method GET -Uri 'http://localhost/ocsp' -TimeoutSec 5 -ErrorAction Stop | Out-Null return $true } catch [System.Net.WebException] { $sc = try { [int]$_.Exception.Response.StatusCode } catch { 0 } return ($sc -ne 0 -and $sc -ne 404) } catch { return $false } } # --------------------------------------------------------------------------- function Get-CAOcspWebAppPath { <# .SYNOPSIS Read-only. Returns the appcmd-style "<site>/ocsp" identifier for the Online Responder's IIS application (needed to target it with `appcmd set config`), or $null if appcmd/the app aren't found. Site name varies by box (usually "Default Web Site", not guaranteed), so this resolves it rather than assuming. #> $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe' if (-not (Test-Path $appcmd)) { return $null } try { $out = & $appcmd list app 2>$null | Out-String $m = [regex]::Match($out, '(?im)APP\s+"([^"]*/ocsp)"') if ($m.Success) { return $m.Groups[1].Value } } catch { } return $null } # --------------------------------------------------------------------------- function Set-CAOcspWebRequestLimits { <# .SYNOPSIS Raises IIS request-filtering limits (maxUrl / maxQueryString) on the /ocsp application. Base64-encoded OCSP GET request tokens can exceed IIS's defaults (maxUrl 4096 bytes, maxQueryString 2048 bytes), which IIS answers with HTTP 404.14 / 404.15 rather than passing the request to the ISAPI handler at all. Uses appcmd.exe (ships with Web-Server, no WebAdministration/PSModulePath dependency) rather than Set-WebConfigurationProperty. #> param([Parameter(Mandatory)]$Plan) $appPath = Get-CAOcspWebAppPath if (-not $appPath) { Write-Host " /ocsp IIS application not found - run Install-CAOcspRole first (or apply this by hand once it exists)." -ForegroundColor Yellow return } $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe' Invoke-CAStep -Description "Raise IIS request-filtering limits on '$appPath' (avoids 404.14/404.15 on long base64 OCSP GET tokens)" ` -Commands @( "appcmd.exe set config `"$appPath`" -section:requestFiltering /requestLimits.maxUrl:$($Plan.OcspMaxUrlBytes) /commit:apphost", "appcmd.exe set config `"$appPath`" -section:requestFiltering /requestLimits.maxQueryString:$($Plan.OcspMaxQueryStringBytes) /commit:apphost" ) ` -Action { & $appcmd set config $appPath -section:requestFiltering "/requestLimits.maxUrl:$($Plan.OcspMaxUrlBytes)" /commit:apphost | Out-Null if ($LASTEXITCODE -ne 0) { throw "appcmd set requestLimits.maxUrl failed (exit $LASTEXITCODE)" } & $appcmd set config $appPath -section:requestFiltering "/requestLimits.maxQueryString:$($Plan.OcspMaxQueryStringBytes)" /commit:apphost | Out-Null if ($LASTEXITCODE -ne 0) { throw "appcmd set requestLimits.maxQueryString failed (exit $LASTEXITCODE)" } "ok" } -ContinueOnError | Out-Null } # --------------------------------------------------------------------------- function Set-CAOcspAuditing { <# .SYNOPSIS Enables Online Responder auditing for Start/Stop + Configuration changes + Security settings changes (registry bits 1+2+8=11) - deliberately NOT bit 4 ("requests submitted"), which logs one Security-log event per OCSP query and floods the log. Also enables the OS-level "Certification Services" audit subcategory, which the registry bit alone does not substitute for (same requirement as the CA's own CA\AuditFilter) - without it, nothing reaches the Security log regardless of this setting. .NOTES AuditFilter lives at the RESPONDER ROOT key (script:CAOcspResponderKey), a sibling of the per-CA-config \<name> subkeys, not inside one of them. #> param([Parameter(Mandatory)]$Plan) Invoke-CAStep -Description "Enable Online Responder auditing (Start/Stop + Config + Security changes; excludes per-request)" ` -Commands @( "New-ItemProperty $script:CAOcspResponderKey -Name AuditFilter -PropertyType DWord -Value $($Plan.AuditFilterValue) -Force", "auditpol /set /subcategory:`"$($Plan.AuditFilterOsSubcategory)`" /success:enable /failure:enable", "Restart-Service OCSPSvc -Force" ) ` -Action { New-ItemProperty -Path $script:CAOcspResponderKey -Name 'AuditFilter' -PropertyType DWord -Value $Plan.AuditFilterValue -Force | Out-Null $apOut = & auditpol.exe /set /subcategory:"$($Plan.AuditFilterOsSubcategory)" /success:enable /failure:enable 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw "auditpol failed (exit $LASTEXITCODE): $($apOut.Trim())" } Restart-Service -Name OCSPSvc -Force -ErrorAction Stop "ok" } -ContinueOnError | Out-Null } # --------------------------------------------------------------------------- function Test-CAOcsp { <# .SYNOPSIS Read-only. Returns a [pscustomobject] of bools for the menu-4 preview. Nothing writes. #> param( $Plan, [string]$ExpectedConfigName, [string]$ExpectedCAConfig ) $r = [ordered]@{ RoleInstalled = $null ServiceRunning = $null IsapiAppPresent = $null RevocationConfigPresent = $null RevocationConfigForThisCA = $null SigningCertAcquired = $null EndpointAnswers = $null AuditFilterValue = $null AuditFilterOk = $null IisMaxUrlBytes = $null IisMaxQueryStringBytes = $null IisLimitsOk = $null Working = $false Notes = @() } try { if (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue) { $r.RoleInstalled = [bool](Get-WindowsFeature -Name ADCS-Online-Cert -ErrorAction Stop).Installed } } catch { } try { $r.ServiceRunning = ((Get-Service -Name OCSPSvc -ErrorAction Stop).Status -eq 'Running') } catch { $r.ServiceRunning = $false } $verifyUrl = if ($Plan -and $Plan.LocalVerifyUrl) { $Plan.LocalVerifyUrl } else { 'http://localhost/ocsp' } $r.IsapiAppPresent = Test-CAOcspWebApp $r.EndpointAnswers = Test-CAOcspEndpoint -Url $verifyUrl -RetrySeconds 6 # rides out a just-reloaded 500 # A revocation config is matched by its CAConfig VALUE, not its key name - the OCSP MMC wizard # names configs arbitrarily (e.g. a short client nickname, not the CA common name), so a name lookup misses them. $wantCaConfig = if ($ExpectedCAConfig) { $ExpectedCAConfig } elseif ($Plan) { $Plan.CAConfig } else { $null } $wantName = if ($ExpectedConfigName) { $ExpectedConfigName } elseif ($Plan) { $Plan.ConfigName } else { $null } if (Test-Path $script:CAOcspResponderKey) { $cfgs = @(Get-ChildItem -Path $script:CAOcspResponderKey -ErrorAction SilentlyContinue | ForEach-Object { $props = try { Get-ItemProperty -Path $_.PSPath -ErrorAction Stop } catch { $null } if ($props -and ($props.PSObject.Properties.Name -contains 'CAConfig')) { [pscustomobject]@{ Name = $_.PSChildName; CAConfig = "$($props.CAConfig)"; SigningCertificate = $props.SigningCertificate } } }) $r.RevocationConfigPresent = ($cfgs.Count -gt 0) $match = $cfgs | Where-Object { $wantCaConfig -and $_.CAConfig -ieq $wantCaConfig } | Select-Object -First 1 if (-not $match -and $wantName) { $match = $cfgs | Where-Object { $_.Name -ieq $wantName } | Select-Object -First 1 } if ($match) { $r.RevocationConfigForThisCA = if ($wantCaConfig) { $match.CAConfig -ieq $wantCaConfig } else { -not [string]::IsNullOrWhiteSpace($match.CAConfig) } $r.SigningCertAcquired = ($match.SigningCertificate -and @($match.SigningCertificate).Count -gt 0) } } if (-not $r.SigningCertAcquired -and $Plan) { try { $issuerPat = [regex]::Escape("CN=$($Plan.CACommonName)") $sc = Get-ChildItem Cert:\LocalMachine\My -ErrorAction SilentlyContinue | Where-Object { $_.Issuer -match $issuerPat -and ($_.EnhancedKeyUsageList.ObjectId -contains '1.3.6.1.5.5.7.3.9') } | Select-Object -First 1 if ($sc) { $r.SigningCertAcquired = $true } } catch { } } # Hardening (auditing, IIS request limits) - informational only, NOT part of .Working: OCSP # answers fine without either, they just mean no audit trail / a 404.14-404.15 risk on long GET URLs. try { $af = (Get-ItemProperty -Path $script:CAOcspResponderKey -Name 'AuditFilter' -ErrorAction Stop).AuditFilter $r.AuditFilterValue = $af $want = if ($Plan -and $Plan.AuditFilterValue) { $Plan.AuditFilterValue } else { 11 } $r.AuditFilterOk = (([int]$af -band $want) -eq $want) } catch { $r.AuditFilterOk = $false } $appPath = Get-CAOcspWebAppPath if ($appPath) { try { $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe' $cfgOut = & $appcmd list config $appPath -section:requestFiltering 2>$null | Out-String $mUrl = [regex]::Match($cfgOut, 'maxUrl="(\d+)"') $mQs = [regex]::Match($cfgOut, 'maxQueryString="(\d+)"') if ($mUrl.Success) { $r.IisMaxUrlBytes = [int]$mUrl.Groups[1].Value } if ($mQs.Success) { $r.IisMaxQueryStringBytes = [int]$mQs.Groups[1].Value } $wantUrl = if ($Plan -and $Plan.OcspMaxUrlBytes) { $Plan.OcspMaxUrlBytes } else { 16384 } $wantQs = if ($Plan -and $Plan.OcspMaxQueryStringBytes) { $Plan.OcspMaxQueryStringBytes } else { 8192 } $r.IisLimitsOk = ($r.IisMaxUrlBytes -ge $wantUrl -and $r.IisMaxQueryStringBytes -ge $wantQs) } catch { $r.IisLimitsOk = $false } } $r.Working = [bool]($r.ServiceRunning -and $r.RevocationConfigForThisCA -and $r.SigningCertAcquired -and $r.EndpointAnswers) return [pscustomobject]$r } # ========================================================================= # ENGINES # ========================================================================= function Install-CAOcspRole { <# .SYNOPSIS Ensures the ADCS-Online-Cert feature + Install-AdcsOnlineResponder + a running OCSPSvc. Idempotent. Every mutation via Invoke-CAStep. #> if (-not (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue)) { Write-Host " Server-Manager cmdlets missing - run menu 4 on the CA (a Windows Server)." -ForegroundColor Red return } if (-not (Get-WindowsFeature -Name ADCS-Online-Cert -ErrorAction SilentlyContinue).Installed) { Invoke-CAStep -Description "Add the Online Responder role feature (ADCS-Online-Cert)" ` -Commands @('Install-WindowsFeature -Name ADCS-Online-Cert -IncludeManagementTools') ` -Action { $r = Install-WindowsFeature -Name ADCS-Online-Cert -IncludeManagementTools if (-not $r.Success) { throw "Install-WindowsFeature ADCS-Online-Cert failed (exit $($r.ExitCode))" } $r | Out-String } | Out-Null } else { Write-Host " ADCS-Online-Cert feature already installed." -ForegroundColor Green } $svcPresent = [bool](Get-Service -Name OCSPSvc -ErrorAction SilentlyContinue) $respKey = Test-Path $script:CAOcspResponderKey $webApp = Test-CAOcspWebApp # "role configured" == the responder was set up at least once. Install-AdcsOnlineResponder REFUSES # ("already installed - uninstall first") when it thinks that's the case, so re-running it blindly # to repair a missing /ocsp app can hard-fail. OCSPSvc existing is the tell. $roleConfigured = $svcPresent -or $respKey if ($roleConfigured -and $webApp) { Write-Host " Online Responder already configured (OCSPSvc + /ocsp app present)." -ForegroundColor Green } elseif ($roleConfigured -and -not $webApp) { Write-Host " The Online Responder role is configured but its /ocsp IIS app is missing" -ForegroundColor Yellow Write-Host " (IIS was likely (re)installed after the role). Trying to recreate it..." -ForegroundColor Yellow $repaired = $false Invoke-CAStep -Description "Recreate the /ocsp web app (Install-AdcsOnlineResponder -Force)" ` -Commands @('Import-Module ADCSDeployment', 'Install-AdcsOnlineResponder -Force') ` -Action { Import-Module ADCSDeployment -ErrorAction Stop $r = Install-AdcsOnlineResponder -Force -ErrorAction Stop if ($r -and $r.PSObject.Properties['ErrorId'] -and $r.ErrorId -and $r.ErrorId -ne 0) { throw "Install-AdcsOnlineResponder: $($r.ErrorString) (ErrorId $($r.ErrorId))" } $r | Out-String } -ContinueOnError | Out-Null if (-not (Get-CADryRun)) { $repaired = Test-CAOcspWebApp } if (-not $repaired -and -not (Get-CADryRun)) { Write-Host "" Write-Host " Could not recreate /ocsp automatically. Recover manually, then re-run menu 9:" -ForegroundColor Red Write-Host " 1. Import-Module ADCSDeployment; Uninstall-AdcsOnlineResponder -Force" -ForegroundColor Gray Write-Host " Install-AdcsOnlineResponder -Force" -ForegroundColor Gray Write-Host " 2. if that also fails - feature-level reset:" -ForegroundColor Gray Write-Host " Uninstall-WindowsFeature ADCS-Online-Cert -IncludeManagementTools ; Restart-Computer" -ForegroundColor Gray Write-Host " then Install-WindowsFeature ADCS-Online-Cert -IncludeManagementTools ; Install-AdcsOnlineResponder -Force" -ForegroundColor Gray Write-Host " (revocation configs live in the registry under OCSPSvc\Responder and are re-created by menu 9)" -ForegroundColor Gray return } } else { Invoke-CAStep -Description "Configure the Online Responder (creates /ocsp, starts OCSPSvc)" ` -Commands @('Import-Module ADCSDeployment', 'Install-AdcsOnlineResponder -Force') ` -Action { Import-Module ADCSDeployment -ErrorAction Stop $r = Install-AdcsOnlineResponder -Force if ($r -and $r.PSObject.Properties['ErrorId'] -and $r.ErrorId -and $r.ErrorId -ne 0) { throw "Install-AdcsOnlineResponder: $($r.ErrorString) (ErrorId $($r.ErrorId))" } $r | Out-String } | Out-Null } Invoke-CAStep -Description "Start the Online Responder service (OCSPSvc, Automatic)" ` -Commands @('Set-Service OCSPSvc -StartupType Automatic', 'Start-Service OCSPSvc') ` -Action { Set-Service -Name OCSPSvc -StartupType Automatic -ErrorAction SilentlyContinue Start-Service -Name OCSPSvc -ErrorAction SilentlyContinue for ($i = 0; $i -lt 10; $i++) { if ((Get-Service OCSPSvc -ErrorAction SilentlyContinue).Status -eq 'Running') { break }; Start-Sleep -Seconds 3 } if ((Get-Service OCSPSvc -ErrorAction SilentlyContinue).Status -ne 'Running') { throw "OCSPSvc did not reach Running" } } | Out-Null } # --------------------------------------------------------------------------- function New-CAOcspRevocationConfig { <# .SYNOPSIS Creates / updates ONE revocation configuration under HKLM\...\OCSPSvc\Responder\<name> from a Get-CAOcspPlan, by direct registry writes, then restarts OCSPSvc. Idempotent. -UseCom routes to the CertAdm.OCSPAdmin fallback instead. #> param( [Parameter(Mandatory)]$Plan, [switch]$UseCom ) if (-not (Test-Path $script:CAOcspResponderKey)) { if (-not (Get-CADryRun)) { Write-Host " ...\OCSPSvc\Responder not present - run the role install (Install-CAOcspRole) first." -ForegroundColor Red return } Write-Host " (Responder key not present yet - the role install above would create it; previewing the config writes anyway.)" -ForegroundColor DarkGray } if ($UseCom) { New-CAOcspRevocationConfigViaCom -Plan $Plan; return } # 1. CA cert bytes $der = Get-CAOcspCACertBytes -CACommonName $Plan.CACommonName if (-not $der -and -not (Get-CADryRun)) { throw "Could not locate the CA certificate for CN=$($Plan.CACommonName) in LocalMachine\CA or \My." } # 2. idempotency - is there already a config (this name, or another) serving this CAConfig? $targetName = $Plan.ConfigName $existing = @() if (Test-Path $script:CAOcspResponderKey) { $existing = Get-ChildItem -Path $script:CAOcspResponderKey -ErrorAction SilentlyContinue | ForEach-Object { $cfg = try { (Get-ItemProperty -Path $_.PSPath -ErrorAction Stop).CAConfig } catch { $null } [pscustomobject]@{ Name = $_.PSChildName; CAConfig = $cfg } } } $sameCa = $existing | Where-Object { $_.CAConfig -and ("$($_.CAConfig)" -ieq $Plan.CAConfig) } | Select-Object -First 1 $sameName = $existing | Where-Object { $_.Name -eq $targetName } | Select-Object -First 1 if ($sameCa -and $sameCa.Name -ne $targetName) { Write-Host " Existing revocation config '$($sameCa.Name)' already serves $($Plan.CAConfig) - updating it in place." -ForegroundColor Green $targetName = $sameCa.Name } elseif ($sameName -and $sameName.CAConfig -and "$($sameName.CAConfig)" -ne $Plan.CAConfig) { if (-not (Read-CAConfirm -Prompt " Revocation config '$targetName' currently points at '$($sameName.CAConfig)'. Overwrite for '$($Plan.CAConfig)'?")) { return } } $ck = Join-Path $script:CAOcspResponderKey $targetName $pk = Join-Path $ck 'Provider' $configRows = $Plan.RegistryValues | Where-Object { $_.KeyPath -notmatch '\\Provider$' } $provRows = $Plan.RegistryValues | Where-Object { $_.KeyPath -match '\\Provider$' } if (Test-Path $ck) { # update-in-place: only the drifted non-CACertificate values $cur = try { Get-ItemProperty -Path $ck -ErrorAction Stop } catch { $null } $templateDrifted = $false foreach ($row in ($configRows | Where-Object { $_.Name -ne 'CACertificate' })) { $now = if ($cur) { $cur.$($row.Name) } else { $null } if ("$now" -ne "$($row.Value)") { if ($row.Name -eq 'SigningCertificateTemplate') { $templateDrifted = $true } Invoke-CAStep -Description "Update $($row.Name) on revocation config '$targetName' ($now -> $($row.Value))" ` -Commands @("New-ItemProperty -Path '$ck' -Name $($row.Name) -PropertyType $($row.Type) -Value $($row.Value) -Force") ` -Action { New-ItemProperty -Path $ck -Name $row.Name -PropertyType $row.Type -Value $row.Value -Force | Out-Null } | Out-Null } } if ($templateDrifted) { # The bound signer was enrolled from the OLD template name; leaving SigningCertificate / # SigningCertificateHash in place makes OCSPSvc log 0x80070490 "could not locate a signing # certificate" on the next load. Clear them so SigningFlags 861 (AUTOENROLL) re-binds. Invoke-CAStep -Description "Clear the stale signer binding on '$targetName' (template changed - force re-enroll)" ` -Commands @("Remove-ItemProperty -Path '$ck' -Name SigningCertificate,SigningCertificateHash -Force") ` -Action { Remove-ItemProperty -Path $ck -Name 'SigningCertificate' -Force -ErrorAction SilentlyContinue Remove-ItemProperty -Path $ck -Name 'SigningCertificateHash' -Force -ErrorAction SilentlyContinue } | Out-Null } $curP = try { Get-ItemProperty -Path $pk -ErrorAction Stop } catch { $null } foreach ($row in $provRows) { $now = if ($curP) { $curP.$($row.Name) } else { $null } if (($now -join '|') -ne (@($row.Value) -join '|')) { Invoke-CAStep -Description "Update $($row.Name) on '$targetName\Provider'" ` -Commands @("New-ItemProperty -Path '$pk' -Name $($row.Name) -PropertyType $($row.Type) -Value <...> -Force") ` -Action { if (-not (Test-Path $pk)) { New-Item -Path $pk -Force | Out-Null }; New-ItemProperty -Path $pk -Name $row.Name -PropertyType $row.Type -Value $row.Value -Force | Out-Null } | Out-Null } } Write-Host " Revocation config '$targetName' reconciled." -ForegroundColor Green } else { # create from scratch Invoke-CAStep -Description "Create OCSP revocation config '$targetName' (base values)" ` -Commands @( "New-Item -Path '$ck' -Force" ($configRows | ForEach-Object { "New-ItemProperty -Path '$ck' -Name $($_.Name) -PropertyType $($_.Type) -Value $(if ($_.Name -eq 'CACertificate') { '<DER bytes>' } else { $_.Value }) -Force" }) ) ` -Action { New-Item -Path $ck -Force | Out-Null foreach ($row in $configRows) { $val = if ($row.Name -eq 'CACertificate') { [byte[]]$der } else { $row.Value } New-ItemProperty -Path $ck -Name $row.Name -PropertyType $row.Type -Value $val -Force | Out-Null } } | Out-Null Invoke-CAStep -Description "Create the Provider sub-key + CRL sources" ` -Commands @( "New-Item -Path '$pk' -Force" ($provRows | ForEach-Object { $shown = if ($_.Type -eq 'MultiString') { '@(' + ((@($_.Value)) -join '; ') + ')' } else { "$($_.Value)" } "New-ItemProperty -Path '$pk' -Name $($_.Name) -PropertyType $($_.Type) -Value $shown -Force" }) ) ` -Action { New-Item -Path $pk -Force | Out-Null foreach ($row in $provRows) { New-ItemProperty -Path $pk -Name $row.Name -PropertyType $row.Type -Value $row.Value -Force | Out-Null } } | Out-Null } Invoke-CAStep -Description "Restart OCSPSvc so it loads the revocation config" ` -Commands @('Restart-Service OCSPSvc -Force') ` -Action { Restart-Service -Name OCSPSvc -Force for ($i = 0; $i -lt 10; $i++) { if ((Get-Service OCSPSvc -ErrorAction SilentlyContinue).Status -eq 'Running') { break }; Start-Sleep -Seconds 3 } if ((Get-Service OCSPSvc -ErrorAction SilentlyContinue).Status -ne 'Running') { throw "OCSPSvc did not return to Running after loading the config" } } | Out-Null # Registry writes + a service restart get OCSPSvc to load the config, but they do NOT signal the # responder ARRAY / the /ocsp web proxy to recompile and re-publish it - so the ISAPI can keep # serving a stale (or empty) view and answer HTTP 500. GetConfiguration+SetConfiguration through # the CertAdm.OCSPAdmin COM object (what ocsp.msc does on every OK) forces that push. NON-FATAL - # the config is already on disk; a manual `ocsp.msc` -> the array -> Refresh does the same thing. Publish-CAOcspConfig -ResponderFqdn $Plan.ResponderFqdn } # --------------------------------------------------------------------------- function Publish-CAOcspConfig { <# .SYNOPSIS Forces OCSPSvc to recompile + re-publish its revocation configs to the responder array and the /ocsp web proxy, via CertAdm.OCSPAdmin GetConfiguration/SetConfiguration. NON-FATAL. #> param([string]$ResponderFqdn = "$env:COMPUTERNAME.$((Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).Domain)") Invoke-CAStep -Description "Push the revocation config to the responder array + /ocsp proxy (CertAdm.OCSPAdmin SetConfiguration)" ` -Commands @( "`$admin = New-Object -ComObject CertAdm.OCSPAdmin" "`$admin.GetConfiguration('$ResponderFqdn', `$true)" "`$admin.SetConfiguration('$ResponderFqdn', `$true)" ) ` -Action { $admin = New-Object -ComObject 'CertAdm.OCSPAdmin' try { $admin.GetConfiguration($ResponderFqdn, $true) | Out-Null $admin.SetConfiguration($ResponderFqdn, $true) } finally { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($admin) } } -ContinueOnError | Out-Null } # --------------------------------------------------------------------------- function Confirm-CAOcspSigningCertificate { <# .SYNOPSIS Nudges the responder into acquiring its signing cert from the signing template (poll -> certutil -pulse -> restart OCSPSvc -> re-poll). NON-FATAL - prints a remediation block if it hasn't landed. #> param([Parameter(Mandatory)]$Plan) $probe = { $ck = Join-Path $script:CAOcspResponderKey $Plan.ConfigName $viaReg = $false try { $p = Get-ItemProperty -Path $ck -ErrorAction Stop; $viaReg = ($p.SigningCertificate -and @($p.SigningCertificate).Count -gt 0) } catch { } if ($viaReg) { return $true } try { $issuerPat = [regex]::Escape("CN=$($Plan.CACommonName)") return [bool](Get-ChildItem Cert:\LocalMachine\My -ErrorAction SilentlyContinue | Where-Object { $_.Issuer -match $issuerPat -and ($_.EnhancedKeyUsageList.ObjectId -contains '1.3.6.1.5.5.7.3.9') } | Select-Object -First 1) } catch { return $false } } if (-not (Get-CADryRun) -and (& $probe)) { Write-Host " OCSP signing certificate already acquired." -ForegroundColor Green return } Invoke-CAStep -Description "Trigger machine certificate auto-enrollment (certutil -pulse)" ` -Commands @('certutil -pulse') ` -Action { & certutil.exe -pulse 2>&1 | Out-String; if ($LASTEXITCODE -ne 0) { throw "certutil -pulse failed (exit $LASTEXITCODE)" } } ` -ContinueOnError | Out-Null Invoke-CAStep -Description "Restart OCSPSvc so it binds the freshly enrolled signing certificate" ` -Commands @('Restart-Service OCSPSvc -Force') ` -Action { Restart-Service -Name OCSPSvc -Force } -ContinueOnError | Out-Null if (Get-CADryRun) { return } $ok = $false for ($i = 0; $i -lt 12; $i++) { if (& $probe) { $ok = $true; break }; Start-Sleep -Seconds 5 } if ($ok) { Write-Host " OCSP signing certificate acquired." -ForegroundColor Green } else { Write-Host " Signing certificate not acquired yet. Check:" -ForegroundColor Yellow Write-Host " - certutil -CATemplates lists '$($Plan.SigningCertificateTemplate)'" -ForegroundColor Yellow Write-Host " - the responder machine account ($env:USERDOMAIN\$(($Plan.ResponderFqdn -split '\.')[0])`$) or its group has Enroll on that template" -ForegroundColor Yellow Write-Host " (re-run menu 4 supplying that principal for the OCSP template ACL; check menu 15 too - Enroll alone isn't enough for AutoEnroll)" -ForegroundColor Yellow Write-Host " - the DC / CA is reachable; ocsp.msc will show 'Bad Signing Certificate' until this resolves" -ForegroundColor Yellow } } # --------------------------------------------------------------------------- function New-CAOcspRevocationConfigViaCom { <# .SYNOPSIS DOCUMENTED FALLBACK (only via New-CAOcspRevocationConfig -UseCom). Creates the revocation config through the CertAdm.OCSPAdmin COM object (what ocsp.msc uses). Kept for the case where a hand-written registry config isn't fully consumed by OCSPSvc on a given build. #> param([Parameter(Mandatory)]$Plan) $der = Get-CAOcspCACertBytes -CACommonName $Plan.CACommonName if (-not $der -and -not (Get-CADryRun)) { throw "Could not locate the CA certificate for CN=$($Plan.CACommonName)." } Invoke-CAStep -Description "Create OCSP revocation config '$($Plan.ConfigName)' via CertAdm.OCSPAdmin" ` -Commands @( "`$admin = New-Object -ComObject 'CertAdm.OCSPAdmin'" "`$admin.GetConfiguration('$($Plan.ResponderFqdn)', `$true)" "`$cfg = `$admin.OCSPCAConfigurationCollection.CreateCAConfiguration('$($Plan.ConfigName)', <DER bytes>)" "`$cfg.HashAlgorithm='SHA256'; `$cfg.SigningFlags=861; `$cfg.SigningCertificateTemplate='$($Plan.SigningCertificateTemplate)'; `$cfg.CAConfig='$($Plan.CAConfig)'; `$cfg.ProviderCLSID='$($Plan.ProviderCLSID)'" "`$props = New-Object -ComObject 'CertAdm.OCSPPropertyCollection'; `$props.CreateProperty('BaseCrlUrls', <string[]>); `$props.CreateProperty('RefreshTimeOut', 300000)" "`$cfg.ProviderProperties = `$props.GetAllProperties()" "`$admin.SetConfiguration('$($Plan.ResponderFqdn)', `$true)" ) ` -Action { $admin = New-Object -ComObject 'CertAdm.OCSPAdmin' $admin.GetConfiguration($Plan.ResponderFqdn, $true) $cfg = $admin.OCSPCAConfigurationCollection.CreateCAConfiguration($Plan.ConfigName, [byte[]]$der) $cfg.HashAlgorithm = $Plan.HashAlgorithmId $cfg.SigningFlags = [int]$Plan.SigningFlags $cfg.SigningCertificateTemplate = $Plan.SigningCertificateTemplate $cfg.CAConfig = $Plan.CAConfig $cfg.ProviderCLSID = $Plan.ProviderCLSID $props = New-Object -ComObject 'CertAdm.OCSPPropertyCollection' $props.CreateProperty('BaseCrlUrls', [string[]]$Plan.BaseCrlUrls) if ($Plan.DeltaCrlUrls) { $props.CreateProperty('DeltaCrlUrls', [string[]]$Plan.DeltaCrlUrls) } $props.CreateProperty('RefreshTimeOut', [int]$Plan.RefreshTimeOut) $cfg.ProviderProperties = $props.GetAllProperties() $admin.SetConfiguration($Plan.ResponderFqdn, $true) } | Out-Null } |