09c30f97e9
Consolidated duplicated helpers into Elysium.Common.ps1: - Settings parsing (Read-KeyValueSettingsFile, Read-ElysiumSettings, Get-SettingsValue) - Azure Blob URI builder (Build-BlobUri) - S3 SigV4 signing helpers and AWS module bootstrap - AD credential validation and replication permission pre-check - Parallel execution helper (Get-FunctionDefinitionText) Test-WeakADPasswords.ps1 and Extract-NTHashes.ps1 now import Elysium.Common.ps1 for the first time. Update-KHDB.ps1 and Prepare-KHDBStorage.ps1 removed their local duplicates. Deleted legacy Settings.ps1 (superseded by ElysiumSettings.txt). Removed stray placeholder comment in Elysium.ps1. All versions bumped to unified v2.2.1.
387 lines
15 KiB
PowerShell
387 lines
15 KiB
PowerShell
function Invoke-RestartWithExecutable {
|
|
param(
|
|
[string]$ExecutablePath,
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if (-not $ExecutablePath) { return }
|
|
if (-not $PSCommandPath) { return }
|
|
|
|
$argList = @('-NoLogo', '-NoProfile', '-File', $PSCommandPath)
|
|
|
|
if ($BoundParameters) {
|
|
foreach ($entry in $BoundParameters.GetEnumerator()) {
|
|
$key = "-$($entry.Key)"
|
|
$value = $entry.Value
|
|
if ($value -is [System.Management.Automation.SwitchParameter]) {
|
|
if ($value.IsPresent) { $argList += $key }
|
|
} else {
|
|
$argList += $key
|
|
$argList += $value
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($UnboundArguments) {
|
|
$argList += $UnboundArguments
|
|
}
|
|
|
|
& $ExecutablePath @argList
|
|
exit $LASTEXITCODE
|
|
}
|
|
|
|
function Restart-WithPwshIfAvailable {
|
|
param(
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if ($PSVersionTable.PSVersion.Major -ge 7 -or $PSVersionTable.PSEdition -eq 'Core') { return }
|
|
$pwsh = Get-Command -Name 'pwsh' -ErrorAction SilentlyContinue
|
|
if (-not $pwsh) { return }
|
|
Write-Host ("PowerShell 7 detected at '{0}'; relaunching script under pwsh..." -f $pwsh.Path)
|
|
Invoke-RestartWithExecutable -ExecutablePath $pwsh.Path -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
|
}
|
|
|
|
function Restart-WithWindowsPowerShellIfAvailable {
|
|
param(
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if ($PSVersionTable.PSEdition -eq 'Desktop') { return }
|
|
$powershellCmd = Get-Command -Name 'powershell.exe' -ErrorAction SilentlyContinue
|
|
$powershellPath = $null
|
|
if ($powershellCmd) {
|
|
$powershellPath = $powershellCmd.Path
|
|
} else {
|
|
$defaultPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
if (Test-Path -LiteralPath $defaultPath) {
|
|
$powershellPath = $defaultPath
|
|
}
|
|
}
|
|
if (-not $powershellPath) {
|
|
Write-Warning 'Windows PowerShell (powershell.exe) was not found; continuing under current host.'
|
|
return
|
|
}
|
|
Write-Host ("Windows PowerShell detected at '{0}'; relaunching script under powershell.exe..." -f $powershellPath)
|
|
Invoke-RestartWithExecutable -ExecutablePath $powershellPath -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Read-KeyValueSettingsFile {
|
|
param([Parameter(Mandatory)][string]$Path)
|
|
$result = @{}
|
|
if (-not (Test-Path -LiteralPath $Path)) { return $result }
|
|
foreach ($line in (Get-Content -LiteralPath $Path)) {
|
|
if ($null -eq $line) { continue }
|
|
$trimmed = $line.Trim()
|
|
if (-not $trimmed) { continue }
|
|
if ($trimmed.StartsWith('#')) { continue }
|
|
$kv = $line -split '=', 2
|
|
if ($kv.Count -ne 2) { continue }
|
|
$key = $kv[0].Trim()
|
|
$value = $kv[1].Trim()
|
|
if (-not $key) { continue }
|
|
if ($value.StartsWith("'") -and $value.EndsWith("'") -and $value.Length -ge 2) {
|
|
$value = $value.Substring(1, $value.Length - 2)
|
|
}
|
|
$result[$key] = $value
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function Read-ElysiumSettings {
|
|
param([Parameter(Mandatory)][string]$ScriptRoot)
|
|
$settingsPath = Join-Path -Path $ScriptRoot -ChildPath 'ElysiumSettings.txt'
|
|
if (-not (Test-Path -LiteralPath $settingsPath)) {
|
|
throw "Settings file not found at $settingsPath"
|
|
}
|
|
return Read-KeyValueSettingsFile -Path $settingsPath
|
|
}
|
|
|
|
function Get-SettingsValue {
|
|
param(
|
|
[hashtable]$Settings,
|
|
[string]$Key
|
|
)
|
|
if (-not $Settings) { return $null }
|
|
if ($Settings.ContainsKey($Key)) { return $Settings[$Key] }
|
|
return $null
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parallel execution helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-FunctionDefinitionText {
|
|
param([Parameter(Mandatory)][string]$Name)
|
|
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
|
|
return $cmd.ScriptBlock.Ast.Extent.Text
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Azure Blob Storage helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Build-BlobUri {
|
|
param(
|
|
[string]$Account,
|
|
[string]$Container,
|
|
[string]$Sas,
|
|
[string]$BlobName
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Account)) { throw 'storageAccountName is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($Container)) { throw 'containerName is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($Sas)) { throw 'sasToken is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($BlobName)) { throw 'BlobName cannot be empty.' }
|
|
|
|
$sas = $Sas.Trim()
|
|
if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
|
$normalizedBlob = $BlobName.Replace('\', '/').TrimStart('/')
|
|
$builder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob")
|
|
$builder.Query = $sas.TrimStart('?')
|
|
return $builder.Uri.AbsoluteUri
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Storage path utilities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Combine-StoragePath {
|
|
param(
|
|
[string]$Prefix,
|
|
[string]$Name
|
|
)
|
|
|
|
$cleanName = $Name.Replace('\', '/').TrimStart('/')
|
|
if ([string]::IsNullOrWhiteSpace($Prefix)) { return $cleanName }
|
|
$normalizedPrefix = $Prefix.Replace('\', '/').Trim('/')
|
|
if ([string]::IsNullOrEmpty($normalizedPrefix)) { return $cleanName }
|
|
return "$normalizedPrefix/$cleanName"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AWS SigV4 / S3 helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
|
|
|
|
function Get-HashHex([byte[]]$bytes) {
|
|
if ($null -eq $bytes) { $bytes = [byte[]]@() }
|
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$bytes)
|
|
try {
|
|
$hash = $sha.ComputeHash([System.IO.Stream]$ms)
|
|
} finally { $ms.Dispose() }
|
|
return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
|
|
} finally { $sha.Dispose() }
|
|
}
|
|
|
|
function HmacSha256([byte[]]$key, [string]$data) {
|
|
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
|
|
try {
|
|
$b = [System.Text.Encoding]::UTF8.GetBytes($data)
|
|
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$b)
|
|
try {
|
|
return $h.ComputeHash([System.IO.Stream]$ms)
|
|
} finally { $ms.Dispose() }
|
|
} finally { $h.Dispose() }
|
|
}
|
|
|
|
function GetSignatureKey([string]$secret, [string]$dateStamp, [string]$regionName, [string]$serviceName) {
|
|
$kDate = HmacSha256 (Get-Bytes ('AWS4' + $secret)) $dateStamp
|
|
$kRegion = HmacSha256 $kDate $regionName
|
|
$kService = HmacSha256 $kRegion $serviceName
|
|
HmacSha256 $kService 'aws4_request'
|
|
}
|
|
|
|
function UriEncode([string]$data, [bool]$encodeSlash) {
|
|
if ($null -eq $data) { return '' }
|
|
$enc = [System.Uri]::EscapeDataString($data)
|
|
if (-not $encodeSlash) { $enc = $enc -replace '%2F', '/' }
|
|
return $enc
|
|
}
|
|
|
|
function BuildCanonicalPath([System.Uri]$uri) {
|
|
$segments = $uri.AbsolutePath.Split('/')
|
|
$encoded = @()
|
|
foreach ($s in $segments) { $encoded += (UriEncode $s $false) }
|
|
$path = ($encoded -join '/')
|
|
if (-not $path.StartsWith('/')) { $path = '/' + $path }
|
|
return $path
|
|
}
|
|
|
|
function ToHex([byte[]]$b) { ([BitConverter]::ToString($b)).Replace('-', '').ToLowerInvariant() }
|
|
|
|
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
|
|
$algorithm = 'AWS4-HMAC-SHA256'
|
|
$timestamp = (Get-Date).ToUniversalTime()
|
|
$amzDate = $timestamp.ToString('yyyyMMddTHHmmssZ')
|
|
$dateStamp = $timestamp.ToString('yyyyMMdd')
|
|
$hostHeader = $uri.Host
|
|
if (-not $uri.IsDefaultPort) { $hostHeader = "${hostHeader}:$($uri.Port)" }
|
|
$canonicalUri = BuildCanonicalPath $uri
|
|
$canonicalQueryString = ''
|
|
$canonicalHeaders = "host:$hostHeader`n" + "x-amz-content-sha256:$payloadHash`n" + "x-amz-date:$amzDate`n"
|
|
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
|
$canonicalRequest = "$method`n$canonicalUri`n$canonicalQueryString`n$canonicalHeaders`n$signedHeaders`n$payloadHash"
|
|
$credentialScope = "$dateStamp/$region/s3/aws4_request"
|
|
$stringToSign = "$algorithm`n$amzDate`n$credentialScope`n$((Get-HashHex (Get-Bytes $canonicalRequest)))"
|
|
$signingKey = GetSignatureKey $secretKey $dateStamp $region 's3'
|
|
$signature = ToHex (HmacSha256 $signingKey $stringToSign)
|
|
$authHeader = "$algorithm Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature"
|
|
@{
|
|
'x-amz-date' = $amzDate
|
|
'x-amz-content-sha256' = $payloadHash
|
|
'Authorization' = $authHeader
|
|
}
|
|
}
|
|
|
|
function BuildS3Uri([string]$endpointUrl, [string]$bucket, [string]$key, [bool]$forcePathStyle) {
|
|
$base = [System.Uri]$endpointUrl
|
|
$builder = [System.UriBuilder]::new($base)
|
|
$normalizedKey = $key.Replace('\', '/').TrimStart('/')
|
|
if ($forcePathStyle) {
|
|
$path = $builder.Path.TrimEnd('/')
|
|
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
$builder.Path = ($path.TrimEnd('/') + '/' + $bucket + '/' + $normalizedKey)
|
|
} else {
|
|
$builder.Host = "$bucket." + $builder.Host
|
|
$path = $builder.Path.TrimEnd('/')
|
|
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
$builder.Path = ($path.TrimEnd('/') + '/' + $normalizedKey)
|
|
}
|
|
return $builder.Uri
|
|
}
|
|
|
|
function Ensure-AWSS3Module {
|
|
try { $null = [Amazon.S3.AmazonS3Client]; return } catch {}
|
|
try { Import-Module -Name AWS.Tools.S3 -ErrorAction Stop; return } catch {}
|
|
try { Import-Module -Name AWSPowerShell.NetCore -ErrorAction Stop; return } catch {}
|
|
throw "AWS Tools for PowerShell not found. Install with: Install-Module AWS.Tools.S3 -Scope CurrentUser"
|
|
}
|
|
|
|
function New-S3Client {
|
|
param(
|
|
[string]$EndpointUrl,
|
|
[string]$Region,
|
|
[string]$AccessKeyId,
|
|
[string]$SecretAccessKey,
|
|
[bool]$ForcePathStyle = $true
|
|
)
|
|
Ensure-AWSS3Module
|
|
$creds = New-Object Amazon.Runtime.BasicAWSCredentials($AccessKeyId, $SecretAccessKey)
|
|
$cfg = New-Object Amazon.S3.AmazonS3Config
|
|
if ($EndpointUrl) { $cfg.ServiceURL = $EndpointUrl }
|
|
if ($Region) { try { $cfg.RegionEndpoint = [Amazon.RegionEndpoint]::GetBySystemName($Region) } catch {} }
|
|
$cfg.ForcePathStyle = [bool]$ForcePathStyle
|
|
return (New-Object Amazon.S3.AmazonS3Client($creds, $cfg))
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Active Directory credential and permission helpers
|
|
# (requires the ActiveDirectory module to be loaded before calling)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-ValidatedADCredential {
|
|
param (
|
|
[Parameter(Mandatory)][string]$DomainName,
|
|
[Parameter(Mandatory)][string]$Server,
|
|
[int]$MaxAttempts = 3
|
|
)
|
|
|
|
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
|
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $DomainName (attempt $attempt/$MaxAttempts)"
|
|
if ($null -eq $credential) {
|
|
throw "Credential prompt was cancelled."
|
|
}
|
|
|
|
try {
|
|
Get-ADDomain -Server $Server -Credential $credential -ErrorAction Stop | Out-Null
|
|
Write-Verbose ("Credential pre-check succeeded for '{0}' against '{1}'." -f $credential.UserName, $Server)
|
|
return $credential
|
|
} catch {
|
|
$message = $_.Exception.Message
|
|
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
|
Write-Warning ("Credentials were rejected for '{0}' (attempt {1}/{2})." -f $credential.UserName, $attempt, $MaxAttempts)
|
|
if ($attempt -lt $MaxAttempts) { continue }
|
|
throw "Credentials were rejected by domain controller '$Server' after $MaxAttempts attempts."
|
|
}
|
|
throw "Credential pre-check failed against '$Server': $message"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-ReplicationPermissions {
|
|
param(
|
|
[Parameter(Mandatory)][string]$DomainDN,
|
|
[Parameter(Mandatory)][string]$Server,
|
|
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
|
)
|
|
|
|
$requiredRights = [ordered]@{
|
|
'Replicating Directory Changes' = [guid]'1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
|
|
'Replicating Directory Changes All' = [guid]'1131f6ab-9c07-11d1-f79f-00c04fc2dcd2'
|
|
'Replicating Directory Changes In Filtered Set' = [guid]'89e95b76-444d-4c62-991a-0facbeda640c'
|
|
}
|
|
|
|
$callerSids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
try {
|
|
$samName = $Credential.UserName -replace '^.*\\', ''
|
|
$adUser = Get-ADUser -Identity $samName -Server $Server -Credential $Credential `
|
|
-Properties SID, MemberOf -ErrorAction Stop
|
|
[void]$callerSids.Add($adUser.SID.Value)
|
|
foreach ($groupDN in @($adUser.MemberOf)) {
|
|
try {
|
|
$g = Get-ADGroup -Identity $groupDN -Server $Server -Credential $Credential `
|
|
-Properties SID -ErrorAction Stop
|
|
[void]$callerSids.Add($g.SID.Value)
|
|
} catch { }
|
|
}
|
|
} catch {
|
|
Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
return
|
|
}
|
|
|
|
$acl = $null
|
|
try {
|
|
$de = New-Object System.DirectoryServices.DirectoryEntry(
|
|
"LDAP://$Server/$DomainDN",
|
|
$Credential.UserName,
|
|
$Credential.GetNetworkCredential().Password
|
|
)
|
|
$acl = $de.ObjectSecurity.GetAccessRules(
|
|
$true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
} catch {
|
|
Write-Warning ("Could not read domain object ACL for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
return
|
|
}
|
|
|
|
$missing = @()
|
|
foreach ($rightName in $requiredRights.Keys) {
|
|
$guid = $requiredRights[$rightName]
|
|
$granted = $false
|
|
foreach ($ace in $acl) {
|
|
if ($ace.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }
|
|
if (-not ($ace.ActiveDirectoryRights -band [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight)) { continue }
|
|
if ($ace.ObjectType -ne $guid) { continue }
|
|
if ($callerSids.Contains($ace.IdentityReference.Value)) { $granted = $true; break }
|
|
}
|
|
if (-not $granted) { $missing += $rightName }
|
|
}
|
|
|
|
if ($missing.Count -gt 0) {
|
|
throw ("Account '{0}' is missing the following replication permissions on '{1}':`n - {2}`n`nGrant these extended rights on the domain object to allow DCSync-based hash retrieval." -f `
|
|
$Credential.UserName, $DomainDN, ($missing -join "`n - "))
|
|
}
|
|
|
|
Write-Host ("[+] Replication permissions verified for '{0}'." -f $Credential.UserName)
|
|
}
|