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}: $_"
            }
        }
    }
}