Public/Test-CwCrlHealth.ps1
|
function Test-CwCrlHealth { <# .SYNOPSIS Checks base and delta CRL health: remaining validity, CDP reachability and signature validation against the CRL's actual issuer. .DESCRIPTION Input modes: -CrlUrl : downloads and checks the CRL at this URL (HTTP or LDAP). This is the URL a relying party finds in a certificate's CDP extension. A delta CRL is probed via the Microsoft '+' naming convention. -CaCertificate : extracts CDP URLs (extension 2.5.29.31) from the certificate and checks the referenced CRL. Note the X.509 semantics: a certificate's CDP points to the CRL of its *issuer* - for a sub-CA certificate this monitors the parent CA's CRL. -CrlPath : parses a CRL file directly (offline analysis). -CrlBytes : parses raw CRL bytes. Staleness rules: 'already stale' (NextUpdate in the past) maps to CRITICAL via -CritHours (default 0), 'stale imminent' (NextUpdate within -WarnHours, default 24) maps to WARNING. Signature validation always verifies against certificates whose subject matches the CRL's issuer DN (an explicitly supplied -IssuerCertificate first, then the LocalMachine CA/Root stores). $null means "no issuer certificate available to verify against"; $false means a matching issuer certificate was found but the signature does not verify. .EXAMPLE Test-CwCrlHealth -CrlUrl 'http://pki.contoso.local/CertEnroll/contoso-CA01.crl' .EXAMPLE Get-ChildItem Cert:\LocalMachine\CA | Test-CwCrlHealth -CheckCdp .EXAMPLE Test-CwCrlHealth -CrlPath .\contoso-CA01.crl #> [CmdletBinding(DefaultParameterSetName = 'CaCertificate')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'CaCertificate', ValueFromPipeline)] [System.Security.Cryptography.X509Certificates.X509Certificate2]$CaCertificate, [Parameter(Mandatory, ParameterSetName = 'CrlUrl')] [string[]]$CrlUrl, [Parameter(Mandatory, ParameterSetName = 'CrlPath')] [string]$CrlPath, [Parameter(Mandatory, ParameterSetName = 'CrlBytes')] [byte[]]$CrlBytes, [System.Security.Cryptography.X509Certificates.X509Certificate2]$IssuerCertificate, [Parameter(ParameterSetName = 'CaCertificate')] [switch]$CheckCdp, [double]$WarnHours = 24, [double]$CritHours = 0, [int]$TimeoutSec = 15 ) begin { function Get-CwCrlSignerCandidate { # All certificates whose subject matches the CRL's issuer DN: # the explicitly preferred one first, then LocalMachine CA/Root. param([string]$IssuerDn, [System.Security.Cryptography.X509Certificates.X509Certificate2]$Preferred) if (-not $IssuerDn) { return @() } $candidates = [System.Collections.Generic.List[object]]::new() if ($Preferred -and $Preferred.Subject -eq $IssuerDn) { $candidates.Add($Preferred) } foreach ($storePath in 'Cert:\LocalMachine\CA', 'Cert:\LocalMachine\Root') { try { foreach ($cert in (Get-ChildItem -Path $storePath -ErrorAction Stop | Where-Object { $_.Subject -eq $IssuerDn })) { if (-not ($candidates | Where-Object Thumbprint -eq $cert.Thumbprint)) { $candidates.Add($cert) } } } catch { Write-Verbose "Store $storePath not readable: $_" } } @($candidates) } function Test-CwCrlSignature { # $true - at least one issuer-DN-matching certificate verifies the signature # $false - matching certificate(s) found, none verifies # $null - nothing to verify against (or unsupported algorithm) param([pscustomobject]$Parsed, [object[]]$Candidates) if (-not $Parsed.SignatureBytes -or @($Candidates).Count -eq 0) { return $null } $hashAlg = switch -Wildcard ($Parsed.SignatureAlgorithm) { 'sha256*' { [System.Security.Cryptography.HashAlgorithmName]::SHA256 } 'sha384*' { [System.Security.Cryptography.HashAlgorithmName]::SHA384 } 'sha512*' { [System.Security.Cryptography.HashAlgorithmName]::SHA512 } 'sha1*' { [System.Security.Cryptography.HashAlgorithmName]::SHA1 } default { return $null } } $attempted = $false foreach ($candidate in $Candidates) { try { if ($Parsed.SignatureAlgorithm -like '*RSA') { $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($candidate) if (-not $rsa) { continue } $attempted = $true if ($rsa.VerifyData($Parsed.TbsBytes, $Parsed.SignatureBytes, $hashAlg, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)) { return $true } } elseif ($Parsed.SignatureAlgorithm -like '*ECDSA') { $ecdsa = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPublicKey($candidate) if (-not $ecdsa) { continue } $attempted = $true if ($ecdsa.VerifyData($Parsed.TbsBytes, $Parsed.SignatureBytes, $hashAlg, [System.Security.Cryptography.DSASignatureFormat]::Rfc3279DerSequence)) { return $true } } } catch { Write-Verbose "CRL signature attempt against $($candidate.Thumbprint) failed: $_" $attempted = $true } } if ($attempted) { return $false } return $null } function ConvertTo-CwCrlResult { param([pscustomobject]$Parsed, [string]$SourceUrl, [object[]]$CdpUrls, [object[]]$CdpResults, [object]$SignatureValid, [double]$WarnHours, [double]$CritHours) $hoursRemaining = $null $severity = 'UNKNOWN' if ($Parsed.NextUpdate) { $hoursRemaining = [math]::Round(($Parsed.NextUpdate - (Get-Date).ToUniversalTime()).TotalHours, 2) $severity = ConvertTo-CwSeverity -HoursRemaining $hoursRemaining -WarnHours $WarnHours -CritHours $CritHours } $unreachable = @($CdpResults | Where-Object { -not $_.Reachable }) if ($unreachable.Count -gt 0 -and $severity -ne 'CRITICAL') { $severity = 'WARNING' } if ($SignatureValid -eq $false) { $severity = 'CRITICAL' } [pscustomobject]@{ PSTypeName = 'CertWatchtower.CrlHealth' Issuer = $Parsed.Issuer Type = if ($Parsed.IsDelta) { 'Delta' } else { 'Base' } SourceUrl = $SourceUrl ThisUpdate = $Parsed.ThisUpdate NextUpdate = $Parsed.NextUpdate HoursRemaining = $hoursRemaining Stale = ($null -ne $hoursRemaining -and $hoursRemaining -le 0) StaleImminent = ($null -ne $hoursRemaining -and $hoursRemaining -gt 0 -and $hoursRemaining -le $WarnHours) RevokedCount = $Parsed.RevokedCount SignatureAlg = $Parsed.SignatureAlgorithm SignatureValid = $SignatureValid CdpUrls = @($CdpUrls) CdpResults = @($CdpResults) Severity = $severity } } function Get-CwCrlDownload { param([string]$Url, [int]$TimeoutSec) if ($Url -match '^https?://') { $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop return [byte[]]$response.Content } if ($Url -match '^ldap://') { # LDAP CDP: fetch certificateRevocationList attribute via ADSI $entry = [ADSI]($Url -replace '\?.*$', '') $value = $entry.Properties['certificateRevocationList'].Value if (-not $value) { $value = $entry.Properties['certificateRevocationList;binary'].Value } if ($value) { return [byte[]]$value } throw "LDAP CDP returned no certificateRevocationList attribute." } throw "Unsupported CDP scheme: $Url" } function ConvertTo-CwUnreachableResult { param([string]$Issuer, [string]$SourceUrl, [object[]]$CdpUrls, [object[]]$CdpResults) [pscustomobject]@{ PSTypeName = 'CertWatchtower.CrlHealth' Issuer = $Issuer Type = 'Base' SourceUrl = $SourceUrl ThisUpdate = $null NextUpdate = $null HoursRemaining = $null Stale = $null StaleImminent = $null RevokedCount = $null SignatureAlg = $null SignatureValid = $null CdpUrls = @($CdpUrls) CdpResults = @($CdpResults) Severity = 'CRITICAL' } } } process { # --- direct file/bytes mode ------------------------------------------ if ($PSCmdlet.ParameterSetName -in 'CrlPath', 'CrlBytes') { $parsed = if ($PSCmdlet.ParameterSetName -eq 'CrlPath') { ConvertFrom-CwCrl -Path $CrlPath } else { ConvertFrom-CwCrl -Bytes $CrlBytes } $candidates = Get-CwCrlSignerCandidate -IssuerDn $parsed.Issuer -Preferred $IssuerCertificate $sigValid = Test-CwCrlSignature -Parsed $parsed -Candidates $candidates return (ConvertTo-CwCrlResult -Parsed $parsed -SourceUrl $(if ($CrlPath) { $CrlPath } else { '<bytes>' }) ` -CdpUrls @() -CdpResults @() -SignatureValid $sigValid -WarnHours $WarnHours -CritHours $CritHours) } # --- URL mode --------------------------------------------------------- if ($PSCmdlet.ParameterSetName -eq 'CrlUrl') { foreach ($url in $CrlUrl) { try { $bytes = Get-CwCrlDownload -Url $url -TimeoutSec $TimeoutSec $parsed = ConvertFrom-CwCrl -Bytes $bytes } catch { Write-Verbose "CRL at $url not usable: $_" ConvertTo-CwUnreachableResult -Issuer $null -SourceUrl $url -CdpUrls @($url) ` -CdpResults @([pscustomobject]@{ Url = $url; Reachable = $false; Error = "$_" }) continue } $candidates = Get-CwCrlSignerCandidate -IssuerDn $parsed.Issuer -Preferred $IssuerCertificate $sigValid = Test-CwCrlSignature -Parsed $parsed -Candidates $candidates ConvertTo-CwCrlResult -Parsed $parsed -SourceUrl $url -CdpUrls @($url) ` -CdpResults @([pscustomobject]@{ Url = $url; Reachable = $true; Error = $null }) ` -SignatureValid $sigValid -WarnHours $WarnHours -CritHours $CritHours # delta CRL probe via Microsoft '+' naming convention if (-not $parsed.IsDelta -and $url -match '^https?://.*\.crl$') { $deltaUrl = $url -replace '\.crl$', '+.crl' try { $deltaBytes = Get-CwCrlDownload -Url $deltaUrl -TimeoutSec $TimeoutSec $parsedDelta = ConvertFrom-CwCrl -Bytes $deltaBytes if ($parsedDelta.IsDelta) { $deltaSig = Test-CwCrlSignature -Parsed $parsedDelta -Candidates $candidates ConvertTo-CwCrlResult -Parsed $parsedDelta -SourceUrl $deltaUrl -CdpUrls @($deltaUrl) ` -CdpResults @() -SignatureValid $deltaSig -WarnHours $WarnHours -CritHours $CritHours } } catch { Write-Verbose "No delta CRL at ${deltaUrl}: $_" } } } return } # --- CA certificate mode --------------------------------------------- $cdpUrls = @() $freshestUrls = @() foreach ($ext in $CaCertificate.Extensions) { if ($ext.Oid.Value -eq '2.5.29.31') { $cdpUrls = @([regex]::Matches($ext.Format($false), '(?:https?|ldap)://[^\s,;()]+') | ForEach-Object Value) } elseif ($ext.Oid.Value -eq '2.5.29.46') { $freshestUrls = @([regex]::Matches($ext.Format($false), '(?:https?|ldap)://[^\s,;()]+') | ForEach-Object Value) } } if ($cdpUrls.Count -eq 0) { Write-Verbose "No CDP extension on '$($CaCertificate.Subject)' - nothing to check." return } $cdpResults = @() if ($CheckCdp) { foreach ($url in $cdpUrls) { $reachable = $false; $errMsg = $null try { $null = Get-CwCrlDownload -Url $url -TimeoutSec $TimeoutSec; $reachable = $true } catch { $errMsg = "$_" } $cdpResults += [pscustomobject]@{ Url = $url; Reachable = $reachable; Error = $errMsg } } } # download base CRL from first working CDP (HTTP preferred) $baseCrlBytes = $null; $baseUrl = $null foreach ($url in (@($cdpUrls | Where-Object { $_ -match '^https?://' }) + @($cdpUrls | Where-Object { $_ -match '^ldap://' }))) { try { $baseCrlBytes = Get-CwCrlDownload -Url $url -TimeoutSec $TimeoutSec $baseUrl = $url break } catch { Write-Verbose "CDP $url not usable: $_" } } if (-not $baseCrlBytes) { return (ConvertTo-CwUnreachableResult -Issuer $CaCertificate.Issuer -SourceUrl $null ` -CdpUrls $cdpUrls -CdpResults $cdpResults) } $parsedBase = ConvertFrom-CwCrl -Bytes $baseCrlBytes # X.509 semantics: this CRL is issued by the certificate's ISSUER - # resolve the actual signer by matching the CRL's issuer DN. $preferred = if ($IssuerCertificate) { $IssuerCertificate } else { $CaCertificate } $candidates = Get-CwCrlSignerCandidate -IssuerDn $parsedBase.Issuer -Preferred $preferred $sigValid = Test-CwCrlSignature -Parsed $parsedBase -Candidates $candidates ConvertTo-CwCrlResult -Parsed $parsedBase -SourceUrl $baseUrl -CdpUrls $cdpUrls -CdpResults $cdpResults ` -SignatureValid $sigValid -WarnHours $WarnHours -CritHours $CritHours # delta CRL: freshest-CRL extension, else '+' convention $deltaCandidates = @($freshestUrls | Where-Object { $_ -match '^https?://' }) if ($deltaCandidates.Count -eq 0 -and $baseUrl -match '^https?://.*\.crl$') { $deltaCandidates = @(($baseUrl -replace '\.crl$', '+.crl')) } foreach ($deltaUrl in $deltaCandidates) { try { $deltaBytes = Get-CwCrlDownload -Url $deltaUrl -TimeoutSec $TimeoutSec $parsedDelta = ConvertFrom-CwCrl -Bytes $deltaBytes if (-not $parsedDelta.IsDelta) { continue } $deltaSig = Test-CwCrlSignature -Parsed $parsedDelta -Candidates $candidates ConvertTo-CwCrlResult -Parsed $parsedDelta -SourceUrl $deltaUrl -CdpUrls @($deltaUrl) -CdpResults @() ` -SignatureValid $deltaSig -WarnHours $WarnHours -CritHours $CritHours break } catch { Write-Verbose "No delta CRL at ${deltaUrl}: $_" } } } } |