Private/AD/Core/Get-ADCertificateServices.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Get-ADCertificateServices {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Connection,

        [switch]$Quiet
    )

    $result = @{
        CertificateAuthorities = @()
        CertificateTemplates   = @()
        PKIObjects             = @()
        NTAuthCertificates     = $null
        OIDObjects             = @()
        Errors                 = @{}
    }

    $configDN = $Connection.ConfigDN
    $pkiServicesDN = "CN=Public Key Services,CN=Services,$configDN"

    # Well-known EKU OIDs for reference
    $ekuMap = @{
        '2.5.29.37.0'                = 'Any Purpose'
        '1.3.6.1.5.5.7.3.2'         = 'Client Authentication'
        '1.3.6.1.4.1.311.20.2.2'    = 'Smart Card Logon'
        '1.3.6.1.5.2.3.4'           = 'PKINIT Client Authentication'
        '1.3.6.1.4.1.311.20.2.1'    = 'Certificate Request Agent'
        '1.3.6.1.5.5.7.3.1'         = 'Server Authentication'
        '1.3.6.1.5.5.7.3.4'         = 'Secure Email'
        '1.3.6.1.4.1.311.10.3.4'    = 'Encrypting File System'
        '1.3.6.1.4.1.311.54.1.2'    = 'Remote Desktop Authentication'
    }

    # Enrollment right GUIDs
    $enrollGuid      = '0e10c968-78fb-11d2-90d4-00c04f79dc55'
    $autoEnrollGuid  = 'a05b8cc2-17bc-4802-a710-e7c15ab866a2'

    # ── Helper: Parse security descriptor for enrollment permissions ─────
    function Get-EnrollmentPermissions {
        param([byte[]]$SecurityDescriptorBytes, [System.DirectoryServices.DirectoryEntry]$LookupRoot)

        $permissions = [System.Collections.Generic.List[hashtable]]::new()
        if ($null -eq $SecurityDescriptorBytes -or $SecurityDescriptorBytes.Count -eq 0) {
            return @($permissions)
        }

        try {
            $sd = New-Object System.DirectoryServices.ActiveDirectorySecurity
            $sd.SetSecurityDescriptorBinaryForm($SecurityDescriptorBytes)

            foreach ($ace in $sd.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
                if ($ace.AccessControlType -ne 'Allow') { continue }

                $sidString = $ace.IdentityReference.Value
                $identity = Resolve-ADSid -SidString $sidString -SearchRoot $LookupRoot

                # Check for Enroll extended right
                if ($ace.ObjectType -and $ace.ObjectType.ToString().ToLower() -eq $enrollGuid) {
                    $permissions.Add(@{
                        Identity = $identity
                        SID      = $sidString
                        Right    = 'Enroll'
                    })
                }

                # Check for AutoEnroll extended right
                if ($ace.ObjectType -and $ace.ObjectType.ToString().ToLower() -eq $autoEnrollGuid) {
                    $permissions.Add(@{
                        Identity = $identity
                        SID      = $sidString
                        Right    = 'AutoEnroll'
                    })
                }

                # Check for GenericAll / Full Control
                $genericAllMask = [System.DirectoryServices.ActiveDirectoryRights]::GenericAll
                if (($ace.ActiveDirectoryRights -band $genericAllMask) -eq $genericAllMask) {
                    $permissions.Add(@{
                        Identity = $identity
                        SID      = $sidString
                        Right    = 'FullControl'
                    })
                }

                # Check for WriteDacl / WriteOwner (dangerous permissions for ESC4/ESC5)
                $writeDaclMask  = [System.DirectoryServices.ActiveDirectoryRights]::WriteDacl
                $writeOwnerMask = [System.DirectoryServices.ActiveDirectoryRights]::WriteOwner
                if (($ace.ActiveDirectoryRights -band $writeDaclMask) -eq $writeDaclMask) {
                    $permissions.Add(@{
                        Identity = $identity
                        SID      = $sidString
                        Right    = 'WriteDacl'
                    })
                }
                if (($ace.ActiveDirectoryRights -band $writeOwnerMask) -eq $writeOwnerMask) {
                    $permissions.Add(@{
                        Identity = $identity
                        SID      = $sidString
                        Right    = 'WriteOwner'
                    })
                }

                # Check for WriteProperty (can modify template attributes - ESC4)
                $writePropertyMask = [System.DirectoryServices.ActiveDirectoryRights]::WriteProperty
                if (($ace.ActiveDirectoryRights -band $writePropertyMask) -eq $writePropertyMask) {
                    # Only flag WriteProperty on all properties (ObjectType = Guid.Empty)
                    if ($null -eq $ace.ObjectType -or $ace.ObjectType -eq [guid]::Empty) {
                        $permissions.Add(@{
                            Identity = $identity
                            SID      = $sidString
                            Right    = 'WriteAllProperties'
                        })
                    }
                }
            }
        } catch {
            Write-Verbose "Failed to parse security descriptor: $_"
        }

        return @($permissions)
    }

    # ── Helper: Resolve EKU OID to friendly name ────────────────────────
    function Resolve-EkuOid {
        param([string]$Oid)
        if ($ekuMap.ContainsKey($Oid)) { return $ekuMap[$Oid] }
        return $Oid
    }

    # ── 1. Certificate Authorities ──────────────────────────────────────
    if (-not $Quiet) {
        Write-ProgressLine -Phase RECON -Message 'Enumerating Certificate Authorities'
    }

    try {
        $enrollmentServicesDN = "CN=Enrollment Services,$pkiServicesDN"
        $caRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $enrollmentServicesDN
        $caResults = Invoke-LdapQuery -SearchRoot $caRoot `
            -Filter '(objectClass=pKIEnrollmentService)' `
            -Properties @(
                'cn', 'distinguishedName', 'dNSHostName',
                'cACertificate', 'cACertificateDN', 'flags',
                'certificateTemplates', 'whenCreated', 'whenChanged'
            ) `
            -Scope OneLevel

        $caList = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($ca in $caResults) {
            $templates = @()
            if ($ca.ContainsKey('certificatetemplates')) {
                $templates = if ($ca['certificatetemplates'] -is [array]) {
                    @($ca['certificatetemplates'])
                } else {
                    @($ca['certificatetemplates'])
                }
            }

            $caObj = @{
                Name                = $ca['cn'] ?? ''
                DN                  = $ca['distinguishedname'] ?? ''
                DNSHostName         = if ($ca.ContainsKey('dnshostname')) { $ca['dnshostname'] } else { '' }
                CACertificate       = if ($ca.ContainsKey('cacertificate')) { $ca['cacertificate'] } else { $null }
                CACertificateDN     = if ($ca.ContainsKey('cacertificatedn')) { $ca['cacertificatedn'] } else { '' }
                Flags               = if ($ca.ContainsKey('flags')) { [int]$ca['flags'] } else { 0 }
                CertificateTemplates = $templates
                WhenCreated         = if ($ca.ContainsKey('whencreated')) { $ca['whencreated'] } else { $null }
                WhenChanged         = if ($ca.ContainsKey('whenchanged')) { $ca['whenchanged'] } else { $null }
            }
            $caList.Add($caObj)
        }

        $result.CertificateAuthorities = @($caList)

        if (-not $Quiet) {
            Write-ProgressLine -Phase RECON -Message "Found $($caList.Count) Certificate Authority(ies)"
        }
    } catch {
        Write-Verbose "Failed to enumerate Certificate Authorities: $_"
        $result.Errors['CertificateAuthorities'] = $_.Exception.Message
    }

    # Build set of published template names for IsPublished check
    $publishedTemplates = [System.Collections.Generic.HashSet[string]]::new(
        [StringComparer]::OrdinalIgnoreCase
    )
    foreach ($ca in $result.CertificateAuthorities) {
        foreach ($tmpl in $ca.CertificateTemplates) {
            [void]$publishedTemplates.Add($tmpl)
        }
    }

    # ── 2. Certificate Templates ────────────────────────────────────────
    if (-not $Quiet) {
        Write-ProgressLine -Phase RECON -Message 'Enumerating certificate templates'
    }

    try {
        $templatesDN = "CN=Certificate Templates,$pkiServicesDN"
        $tmplRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $templatesDN
        $lookupRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.DomainDN

        $tmplResults = Invoke-LdapQuery -SearchRoot $tmplRoot `
            -Filter '(objectClass=pKICertificateTemplate)' `
            -Properties @(
                'cn', 'displayName', 'distinguishedName',
                'msPKI-Cert-Template-OID',
                'msPKI-Template-Schema-Version',
                'msPKI-Certificate-Name-Flag',
                'msPKI-Enrollment-Flag',
                'pKIExtendedKeyUsage',
                'msPKI-Certificate-Application-Policy',
                'msPKI-RA-Signature',
                'pKIExpirationPeriod', 'pKIOverlapPeriod',
                'ntSecurityDescriptor',
                'whenCreated', 'whenChanged'
            ) `
            -Scope OneLevel

        $templateList = [System.Collections.Generic.List[hashtable]]::new()
        $tmplCount = 0

        foreach ($tmpl in $tmplResults) {
            $tmplCount++
            if (-not $Quiet -and ($tmplCount % 50 -eq 0)) {
                Write-ProgressLine -Phase RECON -Message 'Processing templates' `
                    -Detail "$tmplCount / $($tmplResults.Count)"
            }

            $name = $tmpl['cn'] ?? ''

            # Certificate Name Flag (critical for ESC1)
            $certNameFlag = 0
            if ($tmpl.ContainsKey('mspki-certificate-name-flag')) {
                $certNameFlag = [int]$tmpl['mspki-certificate-name-flag']
            }
            $enrolleeSuppliesSubject = ($certNameFlag -band 0x1) -ne 0

            # Enrollment Flag
            $enrollmentFlag = 0
            if ($tmpl.ContainsKey('mspki-enrollment-flag')) {
                $enrollmentFlag = [int]$tmpl['mspki-enrollment-flag']
            }

            # Schema Version
            $schemaVersion = 0
            if ($tmpl.ContainsKey('mspki-template-schema-version')) {
                $schemaVersion = [int]$tmpl['mspki-template-schema-version']
            }

            # Extended Key Usage OIDs
            $ekuOids = @()
            if ($tmpl.ContainsKey('pkiextendedkeyusage')) {
                $raw = $tmpl['pkiextendedkeyusage']
                $ekuOids = if ($raw -is [array]) { @($raw) } else { @($raw) }
            }
            $ekuResolved = @($ekuOids | ForEach-Object { @{ OID = $_; Name = (Resolve-EkuOid $_) } })

            # Determine if template allows authentication
            # Any Purpose (empty EKU array or 2.5.29.37.0), Client Auth, Smart Card Logon, PKINIT
            $authenticationOids = @(
                '2.5.29.37.0',
                '1.3.6.1.5.5.7.3.2',
                '1.3.6.1.4.1.311.20.2.2',
                '1.3.6.1.5.2.3.4'
            )
            $allowsAuthentication = ($ekuOids.Count -eq 0) -or
                                    ($ekuOids | Where-Object { $_ -in $authenticationOids }).Count -gt 0

            # Application Policies
            $appPolicies = @()
            if ($tmpl.ContainsKey('mspki-certificate-application-policy')) {
                $raw = $tmpl['mspki-certificate-application-policy']
                $appPolicies = if ($raw -is [array]) { @($raw) } else { @($raw) }
            }

            # RA Signatures Required (0 means no manager approval - relevant for ESC1)
            $raSignatures = 0
            if ($tmpl.ContainsKey('mspki-ra-signature')) {
                $raSignatures = [int]$tmpl['mspki-ra-signature']
            }

            # Parse security descriptor for enrollment permissions
            $enrollmentPermissions = @()
            if ($tmpl.ContainsKey('ntsecuritydescriptor') -and $tmpl['ntsecuritydescriptor'] -is [byte[]]) {
                $enrollmentPermissions = Get-EnrollmentPermissions `
                    -SecurityDescriptorBytes $tmpl['ntsecuritydescriptor'] `
                    -LookupRoot $lookupRoot
            }

            # OID
            $oid = ''
            if ($tmpl.ContainsKey('mspki-cert-template-oid')) {
                $oid = $tmpl['mspki-cert-template-oid']
            }

            $tmplObj = @{
                Name                    = $name
                DisplayName             = if ($tmpl.ContainsKey('displayname')) { $tmpl['displayname'] } else { $name }
                DN                      = $tmpl['distinguishedname'] ?? ''
                OID                     = $oid
                SchemaVersion           = $schemaVersion
                CertificateNameFlag     = $certNameFlag
                EnrolleeSuppliesSubject = $enrolleeSuppliesSubject
                EnrollmentFlag          = $enrollmentFlag
                ExtendedKeyUsage        = $ekuResolved
                ExtendedKeyUsageOIDs    = $ekuOids
                AllowsAuthentication    = $allowsAuthentication
                ApplicationPolicies     = $appPolicies
                RASignaturesRequired    = $raSignatures
                EnrollmentPermissions   = $enrollmentPermissions
                IsPublished             = $publishedTemplates.Contains($name)
                WhenCreated             = if ($tmpl.ContainsKey('whencreated')) { $tmpl['whencreated'] } else { $null }
                WhenChanged             = if ($tmpl.ContainsKey('whenchanged')) { $tmpl['whenchanged'] } else { $null }
            }
            $templateList.Add($tmplObj)
        }

        $result.CertificateTemplates = @($templateList)

        if (-not $Quiet) {
            $publishedCount = @($templateList | Where-Object { $_.IsPublished }).Count
            Write-ProgressLine -Phase RECON -Message "Found $($templateList.Count) certificate template(s)" `
                -Detail "($publishedCount published)"
        }
    } catch {
        Write-Verbose "Failed to enumerate certificate templates: $_"
        $result.Errors['CertificateTemplates'] = $_.Exception.Message
    }

    # ── 3. PKI Objects and ACLs (for ESC5) ──────────────────────────────
    if (-not $Quiet) {
        Write-ProgressLine -Phase RECON -Message 'Auditing PKI container ACLs'
    }

    try {
        $pkiRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $pkiServicesDN
        $lookupRoot2 = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.DomainDN

        $pkiObjects = Invoke-LdapQuery -SearchRoot $pkiRoot `
            -Filter '(objectClass=*)' `
            -Properties @('cn', 'distinguishedName', 'objectClass', 'ntSecurityDescriptor') `
            -Scope OneLevel

        $pkiList = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($pkiObj in $pkiObjects) {
            $objClass = $pkiObj['objectclass']
            if ($objClass -is [array]) { $objClass = $objClass[-1] }

            $permissions = @()
            if ($pkiObj.ContainsKey('ntsecuritydescriptor') -and $pkiObj['ntsecuritydescriptor'] -is [byte[]]) {
                $permissions = Get-EnrollmentPermissions `
                    -SecurityDescriptorBytes $pkiObj['ntsecuritydescriptor'] `
                    -LookupRoot $lookupRoot2
            }

            $pkiList.Add(@{
                Name        = $pkiObj['cn'] ?? ''
                DN          = $pkiObj['distinguishedname'] ?? ''
                ObjectClass = $objClass ?? ''
                Permissions = $permissions
            })
        }

        $result.PKIObjects = @($pkiList)
    } catch {
        Write-Verbose "Failed to audit PKI container ACLs: $_"
        $result.Errors['PKIObjects'] = $_.Exception.Message
    }

    # ── 4. NTAuthCertificates ───────────────────────────────────────────
    if (-not $Quiet) {
        Write-ProgressLine -Phase RECON -Message 'Reading NTAuthCertificates'
    }

    try {
        $ntauthDN = "CN=NTAuthCertificates,$pkiServicesDN"
        $ntauthRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $ntauthDN
        $ntauthResults = @(Invoke-LdapQuery -SearchRoot $ntauthRoot `
            -Filter '(objectClass=certificationAuthority)' `
            -Properties @('cn', 'distinguishedName', 'cACertificate', 'whenCreated', 'whenChanged') `
            -Scope Base)

        if ($ntauthResults.Count -gt 0) {
            $ntauth = $ntauthResults[0]
            $caCerts = @()
            if ($ntauth.ContainsKey('cacertificate')) {
                $raw = $ntauth['cacertificate']
                $caCerts = if ($raw -is [array]) { @($raw) } else { @($raw) }
            }

            $result.NTAuthCertificates = @{
                DN              = $ntauth['distinguishedname'] ?? ''
                CACertificates  = $caCerts
                CertificateCount = $caCerts.Count
                WhenCreated     = if ($ntauth.ContainsKey('whencreated')) { $ntauth['whencreated'] } else { $null }
                WhenChanged     = if ($ntauth.ContainsKey('whenchanged')) { $ntauth['whenchanged'] } else { $null }
            }
        }
    } catch {
        Write-Verbose "NTAuthCertificates not found or not accessible: $_"
        # Not an error — may not exist in environments without AD CS
    }

    # ── 5. OID Objects (for ESC13 issuance policy links) ────────────────
    if (-not $Quiet) {
        Write-ProgressLine -Phase RECON -Message 'Checking OID issuance policy links'
    }

    try {
        $oidContainerDN = "CN=OID,$pkiServicesDN"
        $oidRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $oidContainerDN
        $oidResults = Invoke-LdapQuery -SearchRoot $oidRoot `
            -Filter '(objectClass=msPKI-Enterprise-Oid)' `
            -Properties @(
                'cn', 'distinguishedName', 'displayName',
                'msPKI-Cert-Template-OID',
                'msDS-OIDToGroupLink',
                'flags'
            ) `
            -Scope OneLevel

        $oidList = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($oidObj in $oidResults) {
            $groupLink = $null
            if ($oidObj.ContainsKey('msds-oidtogrouplink')) {
                $groupLink = $oidObj['msds-oidtogrouplink']
            }

            $oidEntry = @{
                Name          = $oidObj['cn'] ?? ''
                DisplayName   = if ($oidObj.ContainsKey('displayname')) { $oidObj['displayname'] } else { '' }
                DN            = $oidObj['distinguishedname'] ?? ''
                OID           = if ($oidObj.ContainsKey('mspki-cert-template-oid')) { $oidObj['mspki-cert-template-oid'] } else { '' }
                GroupLink     = $groupLink
                HasGroupLink  = ($null -ne $groupLink -and $groupLink -ne '')
                Flags         = if ($oidObj.ContainsKey('flags')) { [int]$oidObj['flags'] } else { 0 }
            }
            $oidList.Add($oidEntry)
        }

        $result.OIDObjects = @($oidList)

        $linkedCount = @($oidList | Where-Object { $_.HasGroupLink }).Count
        if (-not $Quiet -and $linkedCount -gt 0) {
            Write-ProgressLine -Phase RECON -Message "Found $linkedCount OID(s) with group links (ESC13 potential)"
        }
    } catch {
        Write-Verbose "OID container not accessible: $_"
        # Not an error — may not exist
    }

    # ── Summary ─────────────────────────────────────────────────────────
    if (-not $Quiet) {
        $summary = "AD CS collection complete: $($result.CertificateAuthorities.Count) CA(s), " +
                   "$($result.CertificateTemplates.Count) template(s)"
        if ($result.Errors.Count -gt 0) {
            $summary += " ($($result.Errors.Count) error(s))"
        }
        Write-ProgressLine -Phase RECON -Message $summary
    }

    return $result
}