Private/10.Domain.Ad.ps1

<#
SPDX-License-Identifier: Apache-2.0
Copyright (c) 2025 Stefan Ploch
#>



#region Replication & Domain Helpers
# ---------------------------------------------------------------------- #
#
# Replication & Domain Helpers
#
# ---------------------------------------------------------------------- #

function Get-ADReplAccount {
    <#
        .SYNOPSIS
        Internal wrapper to allow mocking Get-ADReplAccount in tests.
 
        .DESCRIPTION
        This wrapper ensures a stable symbol exists in the module scope so that
        Pester can mock it even when DSInternals is not installed on the CI runner.
        In real execution it delegates to DSInternals\Get-ADReplAccount.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SamAccountName,
        [string]$Domain,
        [string]$Server,
        [pscredential]$Credential
    )
    # If tests mock this function, the body will not run.
    # Otherwise, delegate to DSInternals if available.
    $mod = Get-Module -Name DSInternals -ListAvailable | Select-Object -First 1
    if (-not $mod) {
        throw "DSInternals module not found. Install 'DSInternals' or run in an environment where tests mock Get-ADReplAccount."
    }
    if (-not (Get-Module -Name DSInternals)) {
        Import-Module -Name DSInternals -ErrorAction Stop | Out-Null
    }
    $replParams = @{
        SamAccountName = $SamAccountName
    }
    if ($Domain) { $replParams.Domain = $Domain }
    if ($Server) { $replParams.Server = $Server }
    if ($Credential) { $replParams.Credential = $Credential }
    return DSInternals\Get-ADReplAccount @replParams -ErrorAction Stop
}

function Resolve-DomainContext {
  <#
        .SYNOPSIS
        Resolve the domain FQDN from explicit input or environment/AD.
    #>

    param(
        [string]$Domain
    )
    if ($Domain) { return $Domain }
    $d = $env:USERDNSDOMAIN
    if (-not $d) {
        try {
            $d = (Get-ADDomain).DNSRoot
        } catch {
            Write-Verbose "Failed to resolve domain FQDN; using USERDNSDOMAIN."
            $d = $env:USERDNSDOMAIN
        }
    }
    if (-not $d) { throw "Unable to resolve domain; specify -Domain."}
    $d
}

function ConvertTo-NetBIOSIfFqdn {
    <#
        .SYNOPSIS
        Convert a domain FQDN to NetBIOS name when possible.
    #>

    param(
        [string]$Domain
    )
    if ($Domain -notmatch '\.') { return $Domain }
    try {
        $ad = Get-ADDomain -Identity $Domain -ErrorAction Stop
        if ($ad.NetBIOSName) { return $ad.NetBIOSName }
    } catch {
        Write-Verbose "Failed to resolve NetBIOS name for '$Domain'; using FQDN."
    }
    return ($Domain.Split('.')[0]).ToUpperInvariant()
}

function Get-ReplicatedAccount {
    <#
        .SYNOPSIS
        Use DSInternals to replicate account secrets for offline key extraction.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SamAccountName,
        [Parameter(Mandatory)][string]$DomainFQDN,
        [string]$Server,
        [pscredential]$Credential
    )

    $netbios = ConvertTo-NetBIOSIfFqdn $DomainFQDN
    if ($Server) {
        $repl = @{
            SamAccountName = $SamAccountName
            Domain         = $netbios
            Server         = $Server
        }
    } else {
        $repl = @{
            SamAccountName = $SamAccountName
            Domain         = $netbios
            Server         = $DomainFQDN
        }
    }

    if ($Credential) { $repl.Credential = $Credential }
    try {
        Get-ADReplAccount @repl -ErrorAction Stop
    } catch {
        throw "Replication (Get-ADReplAccount) failed for '$SamAccountName' in domain '$DomainFqdn'. $($_.Exception.Message)"
    }
}

#endregion


#region Key Extractions
# ---------------------------------------------------------------------- #
#
# Key Extractions
#
# ---------------------------------------------------------------------- #

function Get-KerberosKeyMaterialFromAccount {
    <#
        .SYNOPSIS
        Extract Kerberos key material (etype->key) and KVNO sets from a replicated account.
 
        .DESCRIPTION
        This function utilizes the Get-ADReplAccount cmdlet from the DSInternals module to extract Kerberos key material from a replicated account.
        Both KerberosNew and KerberosKey Attributes are queried.
        The resulting object is deduplicated and includes a risk-level for later security-related processing.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object]$Account,
        [Parameter(Mandatory)][string]$SamAccountName,
        [string]$Server,
        [switch]$IsKrbtgt
    )

    $diag = [System.Collections.Generic.List[string]]::new()
    $supp = $null
    if ($Account -and $Account.PSObject.Properties.Name -contains 'SupplementalCredentials') {
        $supp = $Account.SupplementalCredentials
    }
    $keySets = New-Object System.Collections.Generic.List[object]
    $kvno = $null
    $principalType = if ($IsKrbtgt) { 'Krbtgt' } elseif ($SamAccountName -match '\$$') { 'Computer' } else { 'User' }

    # Get current KVNO (msDS-KeyVersionNumber)
    try {
        if ($principalType -eq 'Computer') {
        $params = @{ Identity = $SamAccountName.TrimEnd('$'); Properties = 'DistinguishedName','msDS-KeyVersionNumber'; ErrorAction = 'Stop' }
        if ($Server) { $params.Server = $Server }
        $obj = Get-ADComputer @params
        $kvAttr = $obj.'msDS-KeyVersionNumber'
        } else {
        $params = @{ Identity = $Account.DistinguishedName; Properties = 'DistinguishedName','msDS-KeyVersionNumber'; ErrorAction = 'Stop' }
        if ($Server) { $params.Server = $Server }
        $obj = Get-ADObject @params
        $kvAttr = $obj.'msDS-KeyVersionNumber'
        }
        if ($kvAttr) { $kvno = [int]$kvAttr; $diag.Add("Resolved KVNO=$kvno from msDS-KeyVersionNumber") }
    } catch {
        $diag.Add("KVNO lookup failed: $($_.Exception.Message)")
    }

    $addKeySet = {
        param(
            $kvnoVal,
            [hashtable]$etypeMapLocal,
            $sourceLabel
        )

        if (-not $kvnoVal) { return }
        if (-not $etypeMapLocal -or $etypeMapLocal.Count -eq 0) { return }
        $keySets.Add([PSCustomObject]@{
        Kvno        = [int]$kvnoVal
        Keys        = $etypeMapLocal
        Source      = $sourceLabel
        RetrievedAt = (Get-Date).ToUniversalTime()
        })
    }

    if ($supp -and $supp.PSObject.Properties.Name -contains 'KerberosNew' -and $supp.KerberosNew) {
        $kerb = $supp.KerberosNew

        $diag.Add("Using KerberosNew structure")
        $groups = @(
        @{ Name='Credentials';            Data=$kerb.Credentials }
        @{ Name='ServiceCredentials';     Data=$kerb.ServiceCredentials }
        @{ Name='OldCredentials';         Data=$kerb.OldCredentials }
        @{ Name='OlderCredentials';       Data=$kerb.OlderCredentials }
        )

        foreach ($g in $groups) {
            $arr = $g.Data
            if (-not $arr) { continue }

            $etypeMapLocal = @{}

            foreach ($entry in $arr) {
                if (-not $entry) { continue }

                if ($entry.PSObject.Properties.Name -notcontains 'Key' -or
                    $entry.PSObject.Properties.Name -notcontains 'KeyType') { continue }

                $keyBytes = $entry.Key
                $etypeId  = $null
                $rawKT = $entry.KeyType

                if ($rawKT -is [int]) { $etypeId = $rawKT }
                elseif ($rawKT -is [enum]) { $etypeId = [int]$rawKT }
                elseif ($rawKT -is [string]) { [int]$tmp=0; if ([int]::TryParse($rawKT,[ref]$tmp)) { $etypeId=$tmp } }
                elseif ($rawKT -and ($rawKT.PSObject.Properties.Name -contains 'Value')) { try { $etypeId = [int]$rawKT.Value } catch { Write-Verbose "Failed to resolve encryption type ID." } }

                if ($keyBytes -and $etypeId) {
                    if (-not $etypeMapLocal.ContainsKey($etypeId)) { $etypeMapLocal[$etypeId] = $keyBytes }
                }
            }
            if ($etypeMapLocal.Count -gt 0) {
                # determine kvno for set
                $kvForSet = $kvno
                if ($IsKrbtgt) {
                    switch ($g.Name) {
                        'Credentials'         { $kvForSet = $kvno }
                        'ServiceCredentials'  { $kvForSet = $kvno }
                        'OldCredentials'      { if ($kvno -gt 0) { $kvForSet = $kvno - 1 } }
                        'OlderCredentials'    { if ($kvno -gt 1) { $kvForSet = $kvno - 2 } }
                    }
                }
                & $addKeySet $kvForSet $etypeMapLocal $g.Name
            }
        }
    } elseif ($Account -and $Account.PSObject.Properties.Name -contains 'KerberosKeys' -and $Account.KerberosKeys) {
        # legacy
        $etypeMapLocal = @{}
        foreach ($k in $Account.KerberosKeys) {
            if (-not $k) { continue }
            $etype = $k.EncryptionType
            $bytes = $k.key
            $id = Get-EtypeIdFromInput $etype
            if ($null -ne $id -and -not $etypeMapLocal.ContainsKey($id)) { $etypeMapLocal[$id] = $bytes}
        }
        & $addKeySet $kvno $etypeMapLocal 'KerberosKeys'
    } else {
        $diag.Add("No recognizable Kerberos credential structure present.")
    }

    if ($keySets.Count -eq 0) {
        throw "No Kerberos key material extracted for '$SamAccountName'."
    }

    # Deduplicate KeySets by (Kvno, Etype)
    $dedup = @{}
    foreach ($ks in $keySets) {
        $kKey = "$($ks.Kvno)"
        if (-not $dedup.ContainsKey($kKey)) {
            $dedup[$kKey] = @{}
        }
        foreach ($etype in $ks.Keys.Keys) {
            if (-not $dedup[$kKey].ContainsKey($etype)) {
                $dedup[$kKey][$etype] = $ks.Keys[$etype]
            }
        }
    }
    $finalSets = New-Object System.Collections.Generic.List[object]
    foreach ($kvKey in ($dedup.Keys | Sort-Object {[int]$_})) {
        $finalSets.Add([pscustomobject]@{
            Kvno        = [int]$kvKey
            Keys        = $dedup[$kvKey]
            Source      = 'Merged'
            RetrievedAt = (Get-Date).ToUniversalTime()
        })
    }
    $isDC = $false
    try {
        $dn = $Account.DistinguishedName
        if ($dn) {
            $upperDn = $dn.ToUpperInvariant()
            if ($upperDn -like '*OU=DOMAIN CONTROLLERS*' -or $upperDn -like '*CN=DOMAIN CONTROLLERS*') { $isDC = $true }
        }
    } catch {
        Write-Verbose "Failed to resolve distinguished name; assuming user account."
        $upperDn = $null
    }

    # Determine privileged user membership (Domain Admins, Enterprise Admins, Schema Admins, Administrators)
    $isPrivileged = $false
    if ($principalType -eq 'User' -and -not $IsKrbtgt) {
        try {
            $isPrivileged = Test-IsPrivilegedUser -Identity $SamAccountName -Server $Server
        } catch {
            Write-Verbose ("Privilege check failed: {0}" -f $_.Exception.Message)
        }
    }

    [pscustomobject]@{
        SamAccountName = $SamAccountName
        PrincipalType  = $principalType
        KeySets        = $finalSets
        Diagnostics    = $diag
        RiskLevel      = if ($SamAccountName.ToUpperInvariant() -eq 'KRBTGT') { 'Critical' }
                        elseif ($principalType -eq 'Computer' -and $isDC) { 'High' }
                        elseif ($principalType -eq 'User' -and $isPrivileged) { 'High' }
                        else { 'Medium' }
    }
}

#endregion


#region Privilege Helpers
function Test-IsPrivilegedUser {
    <#
        .SYNOPSIS
        Checks if a user is a member (directly or transitively) of high-privilege groups.
 
        .DESCRIPTION
        Uses Get-ADPrincipalGroupMembership to resolve transitive group membership and flags
        membership in Domain Admins, Enterprise Admins, Schema Admins, or Administrators.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Identity,
        [string]$Server,
        [pscredential]$Credential
    )

    $params = @{ Identity = $Identity; ErrorAction = 'Stop' }
    if ($Server) { $params.Server = $Server }
    if ($Credential) { $params.Credential = $Credential }

    try {
        $groups = Get-ADPrincipalGroupMembership @params
    } catch {
        Write-Verbose ("Get-ADPrincipalGroupMembership failed for '{0}': {1}" -f $Identity, $_.Exception.Message)
        return $false
    }
    if (-not $groups) { return $false }

    $highNames = @('Domain Admins','Enterprise Admins','Schema Admins','Administrators')
    foreach ($g in $groups) {
        $name = if ($null -ne $g.SamAccountName) { $g.SamAccountName } else { $g.Name }
        if ($name -and ($name -in $highNames)) { return $true }
        # also check some well-known RIDs where possible
        if ($g.ObjectSID) {
            $sidText = $g.ObjectSID.Value
            if ($sidText -match '-512$' -or $sidText -match '-519$' -or $sidText -match '-518$' -or $sidText -match '-544$') { return $true }
        }
    }
    return $false
}
#endregion