Public/Test-CwCrlHealth.ps1
|
function Test-CwCrlHealth { <# .SYNOPSIS Checks base and delta CRL health: remaining validity, CDP reachability and signature validation against the issuer. .DESCRIPTION Three input modes: -CaCertificate : extracts CDP URLs (extension 2.5.29.31) from the CA certificate, downloads the base CRL from the first reachable HTTP CDP, probes all CDP URLs and looks for a delta CRL (Freshest-CRL extension 2.5.29.46 or the Microsoft '+' naming convention). -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 verifies the CRL signature (RSA/ECDSA) against the supplied issuer certificate's public key; $null when not verifiable. .EXAMPLE Get-ChildItem Cert:\LocalMachine\CA | Test-CwCrlHealth -CheckCdp .EXAMPLE Test-CwCrlHealth -CrlPath .\contoso-CA.crl #> [CmdletBinding(DefaultParameterSetName = 'CaCertificate')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'CaCertificate', ValueFromPipeline)] [System.Security.Cryptography.X509Certificates.X509Certificate2]$CaCertificate, [Parameter(Mandatory, ParameterSetName = 'CrlPath')] [string]$CrlPath, [Parameter(Mandatory, ParameterSetName = 'CrlBytes')] [byte[]]$CrlBytes, [Parameter(ParameterSetName = 'CrlPath')] [Parameter(ParameterSetName = 'CrlBytes')] [System.Security.Cryptography.X509Certificates.X509Certificate2]$IssuerCertificate, [Parameter(ParameterSetName = 'CaCertificate')] [switch]$CheckCdp, [double]$WarnHours = 24, [double]$CritHours = 0, [int]$TimeoutSec = 15 ) begin { function Test-CwCrlSignature { param([pscustomobject]$Parsed, [System.Security.Cryptography.X509Certificates.X509Certificate2]$Issuer) if (-not $Issuer -or -not $Parsed.SignatureBytes) { return $null } try { $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 } } if ($Parsed.SignatureAlgorithm -like '*RSA') { $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($Issuer) if (-not $rsa) { return $null } return $rsa.VerifyData($Parsed.TbsBytes, $Parsed.SignatureBytes, $hashAlg, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) } if ($Parsed.SignatureAlgorithm -like '*ECDSA') { $ecdsa = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPublicKey($Issuer) if (-not $ecdsa) { return $null } return $ecdsa.VerifyData($Parsed.TbsBytes, $Parsed.SignatureBytes, $hashAlg, [System.Security.Cryptography.DSASignatureFormat]::Rfc3279DerSequence) } return $null } catch { Write-Verbose "CRL signature verification failed: $_" return $false } } 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" } } 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 } $sigValid = Test-CwCrlSignature -Parsed $parsed -Issuer $IssuerCertificate return (ConvertTo-CwCrlResult -Parsed $parsed -SourceUrl $(if ($CrlPath) { $CrlPath } else { '<bytes>' }) ` -CdpUrls @() -CdpResults @() -SignatureValid $sigValid -WarnHours $WarnHours -CritHours $CritHours) } # --- 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 [pscustomobject]@{ PSTypeName = 'CertWatchtower.CrlHealth' Issuer = $CaCertificate.Subject Type = 'Base' SourceUrl = $null ThisUpdate = $null NextUpdate = $null HoursRemaining = $null Stale = $null StaleImminent = $null RevokedCount = $null SignatureAlg = $null SignatureValid = $null CdpUrls = $cdpUrls CdpResults = $cdpResults Severity = 'CRITICAL' } } $parsedBase = ConvertFrom-CwCrl -Bytes $baseCrlBytes $sigValid = Test-CwCrlSignature -Parsed $parsedBase -Issuer $CaCertificate ConvertTo-CwCrlResult -Parsed $parsedBase -SourceUrl $baseUrl -CdpUrls $cdpUrls -CdpResults $cdpResults ` -SignatureValid $sigValid -WarnHours $WarnHours -CritHours $CritHours # delta CRL: freshest-CRL extension of the base CRL issuer cert, 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 -Issuer $CaCertificate ConvertTo-CwCrlResult -Parsed $parsedDelta -SourceUrl $deltaUrl -CdpUrls @($deltaUrl) -CdpResults @() ` -SignatureValid $deltaSig -WarnHours $WarnHours -CritHours $CritHours break } catch { Write-Verbose "No delta CRL at ${deltaUrl}: $_" } } } } |