Test-MsIdCBATrustStoreConfiguration.ps1

<#
.SYNOPSIS
    Test & report for common mis-configuration issues with the Entra ID Certificate Trust Store

.DESCRIPTION
    The following is a list of checks performed by this cmdlet.

    * CertificateRevocationListUrl Format Validation Test: Checks for a correctly formatted CRL Distribution Point (CDP) URL
    * Certificate Time Validity Test: Checks that the CA certificate being evaluated is time valid
    * CRL Download and Latency Test: Checks to make sure the Certificate Revocation List (CRL) can be downloaded from the configured CRL and that the download completes in less then 12 seconds
    * CRL Size Test: Checks that the CRL is less then 44MB
    * Certificate Trust Chain Test: Checks that any certificate that is not marked as a root has its issuer also present in the certificate store.
    * CRL Authority Test: Checks that the CRL downloaded from the configured CA lists the CA certificate being evaluated as the its authority.
    * CRL Time Validity Test: Checks that the CRL being evaluated is time valid
    * Additional CRL Information: This include properties of the tested CRL including thisUpdate(Issued), nextPublish, nextUpdate(Expiry) and amount of time remaining

    This Powershell cmdlet require Windows command line utility Certutil. This cmdlet can only be run from Windows device.

    Since the CRL Distribution Point (CDP) needs to be accessible to Entra ID. It is best to run this script from outside
    a corporate network on an internet connected Windows device.

.INPUTS
    None

.EXAMPLE
    Test-MsIdCBATrustStoreConfiguration

    Run tests against the current tenant's Certificate Trust Store
.LINK
    https://aka.ms/aadcba

#>

function Test-MsIdCBATrustStoreConfiguration {

    begin {
            ## Due to Certutil Dependency will only run on Windows.
            Try
            {
            certutil /? | Out-Null
            }
            Catch
            {
            Write-Host Certutil not found. This cmdlet can only run on Windows -ForegroundColor Red
            Break
            }
            try {
            if (-not(get-module -Name Microsoft.Graph.Identity.DirectoryManagement)) {
                    import-module Microsoft.Graph.Identity.DirectoryManagement
                }
                if (-not(get-module -Name Microsoft.Graph.Identity.SignIns)) {
                    import-module Microsoft.Graph.Identity.SignIns
                }
            }
            catch {
                Write-Host Microsoft Graph SDK not found. Install the Microwsoft Graph SDK -ForegroundColor Red
                Break
            }
            try {
                $context = Get-MgContext
                if ($null -eq $context) {
                                         Write-Host "Unable to connect to MSGraph. Run Connect-MgGraph prior to this Powershell Cmdlet" -ForegroundColor Red
                                         Break
                                        }
                }
            catch {
                Write-Host "Unable to determine MgContext (Get-MgContext). Resolve issues with Get-MgContext and try again" -ForegroundColor Red
                Break
            }
        }

    process {
# Get Org Info
$OrgInfo = Get-MgOrganization

# Get the list of trusted certificate authorities
$trustedCAs = (Get-MgOrganizationCertificateBasedAuthConfiguration -OrganizationId $OrgInfo.Id).CertificateAuthorities

# Check for a single CA
If($trustedCAs.count -eq 0)
{
    Write-Host "No Certificate Authorities are present in $($OrgInfo.DisplayName - $($OrgInfo.Id))" -ForegroundColor Red
    Break
}

# Loop through each trusted CA
$CompletedResult = @()
foreach ($ca in $trustedCAs) {
    $crlDLTime = $null
    $crldump = $Null
    $crlAKI = $Null
    $crlTU = $Null
    $crlNU = $null

    Write-Host "Processing $($ca.Issuer)"
    ### High Level Check for correctly formatted CDP URL

    If($ca.CertificateRevocationListUrl)
    {
     Write-Host " CertificateRevocationListUrl Format Validation Test"
     $pattern = '^http:\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])\/[^\/]+\.[^\/]+$'
     $crlURLCheckPass = $false
     if ($ca.CertificateRevocationListUrl -match $pattern) {
        Write-Host " Passed" -ForegroundColor Green
        $crlURLCheckPass = $true
     } elseif ($ca.CertificateRevocationListUrl -match '^https:\/\/') {
        Write-Host " HTTPS is not allowed" -ForegroundColor Red
    } else {
        Write-Host " Invalid CDP URL" -ForegroundColor Red
    }
    If(!$crlURLCheckPass)
    {
     ## THis needs to be corrected before other checks
     Write-Host " This CA CDP needs to be corrected. Additional checks for this CA are not processed" -ForegroundColor Red
     Continue
    }
    }

    $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ca.Certificate)

    $objresult = New-Object System.Object
    $objresult | Add-Member -type NoteProperty -name NotAfter -value $cert.NotAfter
    $objresult | Add-Member -type NoteProperty -name NotBefore -value $cert.NotBefore
    $objresult | Add-Member -type NoteProperty -name Subject -value $cert.Subject
    $objresult | Add-Member -type NoteProperty -name Issuer -value $cert.Issuer
    $objresult | Add-Member -type NoteProperty -name Thumbprint -value $cert.Thumbprint

    ForEach($Extension in $Cert.Extensions) {
        Switch($Extension.Oid.FriendlyName) {
            "Authority Key Identifier" {$objresult | Add-Member -type NoteProperty -name Authority-Key-Identifier -value ($Extension.Format($false)).trimstart("KeyID=")}
            "Subject Key Identifier"   {$objresult | Add-Member -type NoteProperty -name Subject-Key-Identifier -value $Extension.Format($false)}
        } ##Switch
    }## ForEach Extension

    $FullCert = $objresult
    $CompletedResult += $objresult

    # Check the Time validity of the certificate
    $now = Get-Date
    Write-Host " Certificate Time Validity Test"
    if ($now -lt $FullCert.NotBefore -or $now -gt $FullCert.NotAfter) {
        Write-Host " Certificate for $($cert.Subject) is not yet valid or expired" -ForegroundColor Red
        continue
    } Else {
        Write-Host " Passed" -ForegroundColor Green
    }

    # Download the CRL
    $TempDir = [System.IO.Path]::GetTempPath()

    If($ca.CertificateRevocationListUrl) {
       Try {
            $crlDLTime = Measure-Command {Invoke-WebRequest -Uri $ca.CertificateRevocationListUrl -OutFile ($TempDir + "crl.crl")}
        } Catch {}

        # Check if the CRL was downloaded successfully
        Write-Host " CRL Download & Latency Test"
        if ($null -eq $crlDLTime) {
            Write-Host " Failed to download CRL for $($cert.Subject)" -ForegroundColor Red
            continue
        } Else {
            if($crlDLTime.TotalSeconds -gt 12) {
                Write-Host " Slow CRL Download (>12 Seconds) for $($cert.Subject)" -ForegroundColor Red
            } Else {
                Write-Host " CRL Download successful for $($cert.Subject)" -ForegroundColor Green
            }
        }
    } Else {
        Write-Host $cert.Subject is not configured with a CRL - Entra ID will not perform CRL check for this CA -ForegroundColor Yellow
        Continue
    }

    ## Check CRL Size
    Write-Host " CRL Size Test"
    $File = Get-ChildItem ($TempDir + "crl.crl")
    $FileMB = [math]::Round($File.Length/1MB,0)
    if($FileMB -gt 44) {
        Write-Host " CRL is Large - $($FileMB) MB- Users may see intermittent Sign-in errors due to sizes above 45" MB -ForegroundColor Red
    } Else {
        If($FileMB -lt 1)
        {
         Write-Host " Passed - CRL is < 1MB" -ForegroundColor Green
        }
        Else
        {
         Write-Host " Passed - CRL is $($FileMB) MB" -ForegroundColor Green
        }
    }

# Validate CA Cert AKI--> SKI Mapping Logic
Write-Host " Certificate Trust Chain Test"
If(($FullCert | Get-Member).name -contains 'Authority-Key-Identifier')
 {
  If([string]::IsNullOrEmpty($FullCert.'Authority-Key-Identifier')) ##Check for Empty AKI
  {
    If($ca.IsRootAuthority)
        {
         Write-Host " CA is configured as a Root Authority --> No Parent Issuer expected in store(AKI Present and Empty)"
        }
   Else {
        Write-Host " CA is not configured as a Root CA and certificate contains empty Authority Key Identifier(AKI) --> This is unexpected" -ForegroundColor Red
        }
  } ## Close Present but Empty AKI
Else ## Non-Empty AKI
  {
    Write-Host " Expected Issuer Subject Key Identifier (SKI) : $($FullCert.'Authority-Key-Identifier')"
    If(!$ca.IsRootAuthority)
        {
         If($FullCert.'Authority-Key-Identifier' -eq $FullCert.'Subject-Key-Identifier')
            {
             Write-Host " CA Authority Key Identifier (AKI) and Subject Key Identifier(SKI) are the same and Cert is not marked as isRootAuthority --> This is unexpected"
            }
         Else
            { ## Non-Empty AKI Non-Root
             If($trustedCAs.IssuerSKI -notcontains $FullCert.'Authority-Key-Identifier')
                {
                 Write-Host " Certificate issuer $($FullCert.'Authority-Key-Identifier') is not present in the tenant certificate store" -ForegroundColor Red
                }
            Else
                {
                 Write-Host " Passed" -ForegroundColor Green
                }
            }
        } ##Close Non Empty NonRoot
    Else
        {
         #Non Empty Root
         If($FullCert.'Authority-Key-Identifier' -eq $FullCert.'Subject-Key-Identifier')
               {
                 Write-Host " Passed" -ForegroundColor Green
               }
        elseif([string]::IsNullOrEmpty($FullCert.'Authority-Key-Identifier'))
            ##Check for Empty AKI
               {
                Write-Host " Passed Certificate issuer is marked as Root and contains empty AKI" -ForegroundColor Green
               }
           Else{
                Write-Host " Certificate issuer is marked as Root but contains AKI that does not match SKI --> This is unexpected"
               }
    }
  }##Close Non-Empty AKI
 }## Close with AKI
 else ## Handle No AKI in Cert at all
     {
        If($ca.IsRootAuthority)
        {
            Write-Host " Passed" -ForegroundColor Green
        }
         Else
        {
            Write-Host " CA Certificate is not marked as Root and doesnot contain AKI --> This is unexpected"
        }
     }## Close No AKI

    # Dump the CRL file using certutil
    Write-Host " "Running Certutil commands and parsing output *** Can be Slow for Big CRL *** -ForegroundColor White
    $crldump = certutil -dump ($TempDir + "crl.crl")
    # Check for a Next Publish Date in CRLDump and grab before truncating the output for faster processing
        $i = 0
        $crlNP = $Null
    ForEach($Line in $crldump) {
        If ($Line -match "Next CRL Publish") {
            $crlNP = ($crldump[$i+1]).TrimStart(' ') | get-date
            break
        }
        $i++
    }

    ## Shorted CRLDump output for faster parsing
    ## Removed due to inconsistent certutil output. AKI is sometimes after the CRL Entries
    #$i = 0
    #ForEach($Line in $crldump) {
    # If ($Line -match "CRL Entries:") {
    # $crldump = $crldump[0..$i]
    # break
    # }
    # $i++
    #}
    #Clear crl values
    $crlAKI = $null
    $crlAKI = $null
    $crlTU = $null
    $crlTU = $null
    $crlNU = $null
    $crlNU = $null
    $MatchingCRL = $false

    $crlAKI = $crldump -match 'KeyID='
    $crlAKI = $crlAKI -replace ' KeyID=',''
    $crlTU = $crldump -match ' ThisUpdate: '
    $crlTU = $crlTU -replace ' ThisUpdate: ','' | get-Date
    $crlNU = $crldump -match ' NextUpdate: '
    $crlNU = $crlNU -replace ' NextUpdate: ','' | get-Date

    # Verify CRL/CERT AKI Match
    Write-Host " CRL AKI matches CA SKI Test"
    If($crlAKI -ne $FullCert.'Subject-Key-Identifier') {
        If($null -eq $crlAKI)
        {
            Write-Host " Unable to determine CRL AKI from Certutul output" -ForegroundColor Red
        }
        else 
        {
              
        # Downloaded CRL AKI does not match expected SKI of CA Certificate
        Write-Host " CRL Authority Key Identifier(AKI) Mismatch" -ForegroundColor Red
        Write-Host " CRL AKI : " $crlAKI -ForegroundColor Red
        Write-Host " Expected AKI : " $FullCert.'Subject-Key-Identifier' -ForegroundColor Red
        }

        ## See if the CRL downloaded AKI matches other CA in Store
        If($trustedCAs.IssuerSKI -contains $crlAKI) {
            $MatchedCA = @()
            $MatchedCA = $trustedCAs | Where-Object {$crlAKI -eq $_.IssuerSki}
            If($MatchedCA) {
                            Write-Host " Downloaded CRL AKI matches another CA certificate in the trusted store : $($MatchedCA.Issuer) & SKI of $($MatchedCA.IssuerSki)" -ForegroundColor Red
                           }
        }
    } Else {
        $MatchingCRL = $true
        Write-Host " " Cert SKI matches CRL AKI -ForegroundColor Green
    }
    If($MatchingCRL)
    {
    # Check CRL Time Validity
    Write-Host " CRL Time Validity Test"
    if ($now -lt $crlTU -or $now -gt $crlNU) {
        Write-Host " CRL for $($cert.Subject) downloaded from $($ca.CertificateRevocationListUrl) is not yet valid or expired" -ForegroundColor Red
    } Else {
        Write-Host " Passed" -ForegroundColor Green
    }

    # Display CRL Lifetime Information
    Write-Host " Additional CRL Information"
    Write-Host " " CRL was Issued on $crlTU
    If($crlNP)
    {
     Write-Host " " CRL nextPublish is $crlNP
    }
    Else
    {
     Write-Host " " CRL does not contain nextPublish date
    }
    Write-Host " " CRL expires on $crlNU
    $TimeLeft = New-TimeSpan -Start $now -End $crlNU
    Write-Host " " CRL is valid for $TimeLeft.Days Days $TimeLeft.Hours Hours
    }
    # TODO Verify the CRL signature
}##ForEach CA
}##Close Process
}## Close Function
#Test-MsIdCBATrustStoreConfiguration