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