diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c755c..f4adba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ Starting with **v2.2.0**, Elysium uses a **unified project version**. All script --- +## [2.2.1] — 2026-06-09 + +### Changed +- **DRY refactoring — shared helpers consolidated into `Elysium.Common.ps1`:** + - Moved `Read-KeyValueSettingsFile`, `Read-ElysiumSettings`, and `Get-SettingsValue` from `Prepare-KHDBStorage.ps1` and `Update-KHDB.ps1` into the common helper. + - Moved `Build-BlobUri` and Azure URI helpers from `Update-KHDB.ps1` into the common helper. + - Moved `Get-FunctionDefinitionText` from all scripts that duplicated it into the common helper. + - Moved `Get-ValidatedADCredential` and `Test-ReplicationPermissions` from `Test-WeakADPasswords.ps1` into the common helper. + - Moved all native S3 SigV4 helpers (`Ensure-AWSS3Module`, `New-S3Client`, `HmacSha256`, `GetSignatureKey`, `BuildAuthHeaders`, `BuildS3Uri`, etc.) from `Extract-NTHashes.ps1` into the common helper. +- `Test-WeakADPasswords.ps1` and `Extract-NTHashes.ps1` now import `Elysium.Common.ps1` (they previously did not), reducing duplication and ensuring consistent behavior. +- `Update-KHDB.ps1` and `Prepare-KHDBStorage.ps1` removed their local copies of helpers already available in the common module. +- Removed legacy `Settings.ps1` (superseded by `ElysiumSettings.txt`). +- Minor cleanup: removed stray placeholder comment in `Elysium.ps1`. + +--- + ## [2.2.0] — 2026-06-09 ### Changed diff --git a/Elysium.Common.ps1 b/Elysium.Common.ps1 index 37c9e1b..f094d86 100644 --- a/Elysium.Common.ps1 +++ b/Elysium.Common.ps1 @@ -68,3 +68,319 @@ function Restart-WithWindowsPowerShellIfAvailable { 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) +} diff --git a/Elysium.ps1 b/Elysium.ps1 index b1b2dd8..8da0156 100644 --- a/Elysium.ps1 +++ b/Elysium.ps1 @@ -7,7 +7,7 @@ ################################################## ## Project: Elysium ## ## File: Elysium.ps1 ## -## Version: 2.2.0 ## +## Version: 2.2.1 ## ## Support: support@cqre.net ## ################################################## @@ -52,8 +52,6 @@ if ([string]::IsNullOrEmpty($passphrase)) { Write-Host "Passphrase found in environment variables." } -# Continue with the rest of your script... - function Start-OrchestratorTranscript { param([string]$BasePath) try { diff --git a/ElysiumSettings.txt.sample b/ElysiumSettings.txt.sample index 8b319f0..3ac8bf2 100644 --- a/ElysiumSettings.txt.sample +++ b/ElysiumSettings.txt.sample @@ -8,7 +8,7 @@ ################################################## ## Project: Elysium ## ## File: ElysiumSettings.txt ## -## Version: 2.2.0 ## +## Version: 2.2.1 ## ## Support: support@cqre.net ## ################################################## diff --git a/Extract-NTHashes.ps1 b/Extract-NTHashes.ps1 index 9d1c23b..07e068b 100644 --- a/Extract-NTHashes.ps1 +++ b/Extract-NTHashes.ps1 @@ -6,8 +6,8 @@ ## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ## ################################################## ## Project: Elysium ## -## File: Extract-NTLMHashes.ps1 ## -## Version: 2.2.0 ## +## File: Extract-NTHashes.ps1 ## +## Version: 2.2.1 ## ## Support: support@cqre.net ## ################################################## @@ -25,6 +25,11 @@ Set-StrictMode -Version Latest $scriptRoot = $PSScriptRoot +[string]$commonHelper = Join-Path -Path $PSScriptRoot -ChildPath 'Elysium.Common.ps1' +if (-not (Test-Path -LiteralPath $commonHelper)) { throw "Common helper not found at $commonHelper" } +. $commonHelper +Restart-WithWindowsPowerShellIfAvailable -BoundParameters $PSBoundParameters -UnboundArguments $MyInvocation.UnboundArguments + function Start-ExtractTranscript { param([string]$BasePath) try { @@ -40,167 +45,18 @@ function Start-ExtractTranscript { function Stop-ExtractTranscript { try { Stop-Transcript | Out-Null } catch {} } -Start-ExtractTranscript -BasePath $scriptRoot -try { - # Import settings -Write-Host "Loading settings..." -$ElysiumSettings = @{} -$settingsPath = Join-Path -Path $scriptRoot -ChildPath "ElysiumSettings.txt" - -if (-not (Test-Path $settingsPath)) { - Write-Error "Settings file not found at $settingsPath" - exit -} - -Get-Content $settingsPath | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace($_) -and -not $_.StartsWith("#")) { - $keyValue = $_ -split '=', 2 - if ($keyValue.Count -eq 2) { - $ElysiumSettings[$keyValue[0].Trim()] = $keyValue[1].Trim() - } - } -} - function Normalize-ReportPath([string]$p) { if ([string]::IsNullOrWhiteSpace($p)) { return (Join-Path -Path $scriptRoot -ChildPath 'Reports') } if ([System.IO.Path]::IsPathRooted($p)) { return $p } return (Join-Path -Path $scriptRoot -ChildPath $p) } -# Storage provider selection (Azure by default) -$storageProvider = $ElysiumSettings['StorageProvider'] -if ([string]::IsNullOrWhiteSpace($storageProvider)) { $storageProvider = 'Azure' } - -# Azure settings -$storageAccountName = $ElysiumSettings['storageAccountName'] -$containerName = $ElysiumSettings['containerName'] -$sasToken = $ElysiumSettings['sasToken'] - -# S3-compatible settings -$s3EndpointUrl = $ElysiumSettings['s3EndpointUrl'] -$s3Region = $ElysiumSettings['s3Region'] -$s3BucketName = $ElysiumSettings['s3BucketName'] -$s3AccessKeyId = $ElysiumSettings['s3AccessKeyId'] -$s3SecretAccessKey = $ElysiumSettings['s3SecretAccessKey'] -$s3ForcePathStyle = $ElysiumSettings['s3ForcePathStyle'] -$s3UseAwsTools = $ElysiumSettings['s3UseAwsTools'] -if ([string]::IsNullOrWhiteSpace($s3Region)) { $s3Region = 'us-east-1' } -try { $s3ForcePathStyle = [System.Convert]::ToBoolean($s3ForcePathStyle) } catch { $s3ForcePathStyle = $true } -try { $s3UseAwsTools = [System.Convert]::ToBoolean($s3UseAwsTools) } catch { $s3UseAwsTools = $false } - -function Ensure-AWSS3Module { - # Ensure AWS SDK types are available via AWS Tools for PowerShell - 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)) -} - -# Native S3 SigV4 (no AWS Tools) helpers -function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) } -function Get-HashHex([byte[]]$bytes) { - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - if ($null -eq $bytes) { $bytes = [byte[]]@() } - $ms = [System.IO.MemoryStream]::new($bytes) - try { - $hashBytes = $sha.ComputeHash($ms) - return ([BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant() - } finally { $ms.Dispose() } - } finally { $sha.Dispose() } -} function Get-FileSha256Hex([string]$path) { $sha = [System.Security.Cryptography.SHA256]::Create() $fs = [System.IO.File]::OpenRead($path) try { return ([BitConverter]::ToString($sha.ComputeHash($fs))).Replace('-', '').ToLowerInvariant() } finally { $fs.Close(); $sha.Dispose() } } -function HmacSha256([byte[]]$key, [string]$data) { - $h = [System.Security.Cryptography.HMACSHA256]::new($key) - try { - $dataBytes = Get-Bytes $data - $ms = [System.IO.MemoryStream]::new($dataBytes) - try { return $h.ComputeHash($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 - return (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 ($seg in $segments) { $encoded += (UriEncode $seg $false) } - $path = ($encoded -join '/') - if (-not $path.StartsWith('/')) { $path = '/' + $path } - return $path -} -function ToHex([byte[]]$bytes) { return ([BitConverter]::ToString($bytes)).Replace('-', '').ToLowerInvariant() } -function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) { - $algorithm = 'AWS4-HMAC-SHA256' - $amzdate = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') - $datestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd') - $hostHeader = $uri.Host - if (-not $uri.IsDefaultPort) { $hostHeader = "{0}:{1}" -f $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" - return @{ '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 - $ub = [System.UriBuilder]::new($base) - if ($forcePathStyle) { - $p = ($ub.Path.TrimEnd('/')) - if ([string]::IsNullOrEmpty($p)) { $p = '/' } - $ub.Path = ($p.TrimEnd('/') + '/' + $bucket + '/' + $key) - } else { - $ub.Host = "$bucket." + $ub.Host - $p = $ub.Path.TrimEnd('/') - if ([string]::IsNullOrEmpty($p)) { $p = '/' } - $ub.Path = ($p.TrimEnd('/') + '/' + $key) - } - return $ub.Uri -} function Invoke-S3PutFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$filePath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) { $uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle $payloadHash = Get-FileSha256Hex -path $filePath @@ -218,6 +74,7 @@ function Invoke-S3PutFile([string]$endpointUrl, [string]$bucket, [string]$key, [ if (-not $resp.IsSuccessStatusCode) { throw "S3 PUT failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase)" } } finally { if ($req) { $req.Dispose() }; if ($stream) { $stream.Close(); $stream.Dispose() }; $client.Dispose() } } + function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$targetPath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) { $uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle $payloadHash = (Get-HashHex (Get-Bytes '')) @@ -236,13 +93,6 @@ function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key, } finally { if ($req) { $req.Dispose() }; $client.Dispose() } } -# Retrieve the passphrase from a user environment variable -$passphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User) -if ([string]::IsNullOrWhiteSpace($passphrase)) { Write-Error 'Passphrase not found in ELYSIUM_PASSPHRASE environment variable.'; exit } - -# Timestamp -$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" - function Protect-FileWithAES { param ( [Parameter(Mandatory = $true)] @@ -255,7 +105,6 @@ function Protect-FileWithAES { [string]$Passphrase ) - # Derive key with PBKDF2 (HMACSHA256) + random salt $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() $salt = New-Object byte[] 16 $rng.GetBytes($salt) @@ -277,7 +126,6 @@ function Protect-FileWithAES { $outFileStream = [System.IO.File]::Create($OutputFile) try { - # File header: magic 'ELY1' (4 bytes), salt (16 bytes), IV (16 bytes) $magic = [System.Text.Encoding]::ASCII.GetBytes('ELY1') $outFileStream.Write($magic, 0, $magic.Length) $outFileStream.Write($salt, 0, $salt.Length) @@ -299,6 +147,7 @@ function Protect-FileWithAES { Write-Host "File has been encrypted (PBKDF2+AES-256-CBC): $OutputFile" } + function Get-FileChecksum { param ( [string]$Path, @@ -309,142 +158,191 @@ function Get-FileChecksum { try { $hashBytes = $hasher.ComputeHash($stream) return [BitConverter]::ToString($hashBytes) -replace '-', '' - } - finally { + } finally { $stream.Close() $hasher.Dispose() } } -# Extract NTLM hashes -$reportBase = Normalize-ReportPath -p $ElysiumSettings['ReportPathBase'] -if (-not (Test-Path $reportBase)) { New-Item -Path $reportBase -ItemType Directory -Force | Out-Null } +Start-ExtractTranscript -BasePath $scriptRoot +try { + Write-Host "Loading settings..." + $ElysiumSettings = Read-ElysiumSettings -ScriptRoot $scriptRoot -# Build domain details from settings (ordered to keep numeric index order) -$DomainDetails = [ordered]@{} -for ($i = 1; $ElysiumSettings.ContainsKey("Domain${i}Name"); $i++) { - $DomainDetails["$i"] = @{ - Name = $ElysiumSettings["Domain${i}Name"] - DC = $ElysiumSettings["Domain${i}DC"] - DA = $ElysiumSettings["Domain${i}DA"] - } -} + # Storage provider selection (Azure by default) + $storageProvider = $ElysiumSettings['StorageProvider'] + if ([string]::IsNullOrWhiteSpace($storageProvider)) { $storageProvider = 'Azure' } -# User selects a domain -Write-Host "Select a domain to extract NTLM hashes:" -$DomainDetails.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" } -$selection = Read-Host "Enter the number of the domain" -$selectedDomain = $DomainDetails[$selection] + # Azure settings + $storageAccountName = $ElysiumSettings['storageAccountName'] + $containerName = $ElysiumSettings['containerName'] + $sasToken = $ElysiumSettings['sasToken'] -if (-not $selectedDomain) { - Write-Error "Invalid selection." - exit -} + # S3-compatible settings + $s3EndpointUrl = $ElysiumSettings['s3EndpointUrl'] + $s3Region = $ElysiumSettings['s3Region'] + $s3BucketName = $ElysiumSettings['s3BucketName'] + $s3AccessKeyId = $ElysiumSettings['s3AccessKeyId'] + $s3SecretAccessKey = $ElysiumSettings['s3SecretAccessKey'] + $s3ForcePathStyle = $ElysiumSettings['s3ForcePathStyle'] + $s3UseAwsTools = $ElysiumSettings['s3UseAwsTools'] + if ([string]::IsNullOrWhiteSpace($s3Region)) { $s3Region = 'us-east-1' } + try { $s3ForcePathStyle = [System.Convert]::ToBoolean($s3ForcePathStyle) } catch { $s3ForcePathStyle = $true } + try { $s3UseAwsTools = [System.Convert]::ToBoolean($s3UseAwsTools) } catch { $s3UseAwsTools = $false } -# Update script variables based on selected domain -$domainController = $selectedDomain.DC -$credential = Get-Credential -Message "Enter AD credentials with replication rights for $($selectedDomain.Name)" + # Retrieve the passphrase from a user environment variable + $passphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User) + if ([string]::IsNullOrWhiteSpace($passphrase)) { throw 'Passphrase not found in ELYSIUM_PASSPHRASE environment variable.' } -$domainPrefix = ($selectedDomain.Name -replace "\W", "_") -$baseName = "${domainPrefix}_NTLM_Hashes_$timestamp" -$exportPath = Join-Path -Path $scriptRoot -ChildPath "$baseName.txt" -$compressedFilePath = Join-Path -Path $scriptRoot -ChildPath "$baseName.zip" -$encryptedFilePath = Join-Path -Path $scriptRoot -ChildPath "$baseName.enc" -$blobName = "$baseName.enc" + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" -$ntlmHashes = Get-ADReplAccount -All -Server $domainController -Credential $credential | -Where-Object { $_.NTHash } | -ForEach-Object { [BitConverter]::ToString($_.NTHash).Replace("-", "") } | -Sort-Object -Unique + $reportBase = Normalize-ReportPath -p $ElysiumSettings['ReportPathBase'] + if (-not (Test-Path $reportBase)) { New-Item -Path $reportBase -ItemType Directory -Force | Out-Null } -$ntlmHashes | Out-File -FilePath $exportPath -Write-Host "NTLM hashes have been extracted to: $exportPath" - -# Compress extracted NTLM hashes -Compress-Archive -Path $exportPath -DestinationPath $compressedFilePath -Write-Host "File has been compressed: $compressedFilePath" - -# Encrypt the compressed file -Protect-FileWithAES -InputFile $compressedFilePath -OutputFile $encryptedFilePath -Passphrase $passphrase -Write-Host "File has been encrypted: $encryptedFilePath" - -# Calculate the local file checksum -$localFileChecksum = Get-FileChecksum -Path $encryptedFilePath - -if ($storageProvider -ieq 'S3') { - # S3-compatible path (e.g., IDrive e2) without requiring AWS Tools - if ([string]::IsNullOrWhiteSpace($s3BucketName)) { Write-Error 's3BucketName is missing in settings.'; exit } - if ([string]::IsNullOrWhiteSpace($s3AccessKeyId) -or [string]::IsNullOrWhiteSpace($s3SecretAccessKey)) { Write-Error 's3AccessKeyId / s3SecretAccessKey missing in settings.'; exit } - if ([string]::IsNullOrWhiteSpace($s3EndpointUrl)) { Write-Error 's3EndpointUrl is required for S3-compatible storage.'; exit } - - $usedAwsTools = $false - if ($s3UseAwsTools) { - try { - $s3Client = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AccessKeyId -SecretAccessKey $s3SecretAccessKey -ForcePathStyle:$s3ForcePathStyle - # Upload - $putReq = New-Object Amazon.S3.Model.PutObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName; FilePath = $encryptedFilePath } - $null = $s3Client.PutObject($putReq) - Write-Host "Encrypted file uploaded to S3-compatible bucket (AWS Tools): $blobName" - $tempDownloadPath = [System.IO.Path]::GetTempFileName() - $getReq = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName } - $getResp = $s3Client.GetObject($getReq) - $getResp.WriteResponseStreamToFile($tempDownloadPath, $true) - $getResp.Dispose() - $downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath - $usedAwsTools = $true - } catch { - Write-Warning "AWS Tools path failed or not available. Falling back to native HTTP (SigV4). Details: $($_.Exception.Message)" - $usedAwsTools = $false + # Build domain details from settings (ordered to keep numeric index order) + $DomainDetails = [ordered]@{} + for ($i = 1; $ElysiumSettings.ContainsKey("Domain${i}Name"); $i++) { + $DomainDetails["$i"] = @{ + Name = $ElysiumSettings["Domain${i}Name"] + DC = $ElysiumSettings["Domain${i}DC"] + DA = $ElysiumSettings["Domain${i}DA"] } } - if (-not $usedAwsTools) { - Invoke-S3PutFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -filePath $encryptedFilePath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle - Write-Host "Encrypted file uploaded to S3-compatible bucket: $blobName" - $tempDownloadPath = [System.IO.Path]::GetTempFileName() - Invoke-S3GetToFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -targetPath $tempDownloadPath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle - $downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath + # User selects a domain + Write-Host "Select a domain to extract NTLM hashes:" + $DomainDetails.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" } + $selection = Read-Host "Enter the number of the domain" + $selectedDomain = $DomainDetails[$selection] + + if (-not $selectedDomain) { + throw "Invalid selection." } -} -else { - # Azure Blob Storage path (default) - $sas = $sasToken - if ([string]::IsNullOrWhiteSpace($sas)) { Write-Error 'sasToken is missing in settings.'; exit } - $sas = $sas.Trim(); if (-not $sas.StartsWith('?')) { $sas = '?' + $sas } - try { Import-Module Az.Storage -ErrorAction Stop } catch {} - $storageContext = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sas - # Ensure container exists - $container = Get-AzStorageContainer -Name $containerName -Context $storageContext -ErrorAction SilentlyContinue - if (-not $container) { Write-Error "Azure container '$containerName' not found or access denied."; exit } + $domainController = $selectedDomain.DC - # Upload the encrypted file to Azure Blob Storage - Set-AzStorageBlobContent -File $encryptedFilePath -Container $containerName -Blob $blobName -Context $storageContext | Out-Null - Write-Host "Encrypted file uploaded to Azure Blob Storage: $blobName" + # Validate credentials and replication permissions before attempting DCSync + $hasADModule = $null -ne (Get-Module -Name ActiveDirectory -ErrorAction SilentlyContinue) + if (-not $hasADModule) { + try { Import-Module ActiveDirectory -ErrorAction Stop; $hasADModule = $true } catch {} + } - # Download the blob to a temporary location to verify - $tempDownloadPath = [System.IO.Path]::GetTempFileName() - Get-AzStorageBlobContent -Blob $blobName -Container $containerName -Context $storageContext -Destination $tempDownloadPath -Force | Out-Null - - # Calculate the downloaded file checksum - $downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath -} - -# Compare the checksums -if ($localFileChecksum -eq $downloadedFileChecksum) { - Write-Host "The file was correctly uploaded. Checksum verified." - # Clean up local and temporary files only on success - Remove-Item -Path $exportPath, $compressedFilePath, $encryptedFilePath, $tempDownloadPath -Force - if ($storageProvider -ieq 'S3') { - Write-Host "Local and temporary files cleaned up after uploading to S3-compatible storage." + if ($hasADModule) { + $credential = Get-ValidatedADCredential -DomainName $selectedDomain.Name -Server $domainController + try { + $domainInfo = Get-ADDomain -Server $domainController -Credential $credential -ErrorAction Stop + Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName ` + -Server $domainController -Credential $credential + } catch { + throw $_.Exception.Message + } } else { - Write-Host "Local and temporary files cleaned up after uploading to Azure Blob Storage." + Write-Warning "ActiveDirectory module not available; skipping credential pre-check and replication permission verification." + $credential = Get-Credential -Message "Enter AD credentials with replication rights for $($selectedDomain.Name)" + if ($null -eq $credential) { throw "Credential prompt was cancelled." } + } + + $domainPrefix = ($selectedDomain.Name -replace "\W", "_") + $baseName = "${domainPrefix}_NTLM_Hashes_$timestamp" + $blobName = "$baseName.enc" + + # Use a temp directory for all sensitive intermediate files so they are + # never written to the installation directory and are always cleaned up. + $tmpDir = New-Item -ItemType Directory -Path ([System.IO.Path]::Combine( + [System.IO.Path]::GetTempPath(), "elysium-extract-" + [System.Guid]::NewGuid())) -Force + $exportPath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.txt" + $compressedFilePath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.zip" + $encryptedFilePath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.enc" + $tempDownloadPath = $null + + try { + $ntlmHashes = Get-ADReplAccount -All -Server $domainController -Credential $credential | + Where-Object { $_.NTHash } | + ForEach-Object { [BitConverter]::ToString($_.NTHash).Replace("-", "") } | + Sort-Object -Unique + + $ntlmHashes | Out-File -FilePath $exportPath + Write-Host "NTLM hashes have been extracted to temporary file." + + Compress-Archive -Path $exportPath -DestinationPath $compressedFilePath + Write-Host "File has been compressed." + + Protect-FileWithAES -InputFile $compressedFilePath -OutputFile $encryptedFilePath -Passphrase $passphrase + + $localFileChecksum = Get-FileChecksum -Path $encryptedFilePath + + if ($storageProvider -ieq 'S3') { + if ([string]::IsNullOrWhiteSpace($s3BucketName)) { throw 's3BucketName is missing in settings.' } + if ([string]::IsNullOrWhiteSpace($s3AccessKeyId) -or [string]::IsNullOrWhiteSpace($s3SecretAccessKey)) { throw 's3AccessKeyId / s3SecretAccessKey missing in settings.' } + if ([string]::IsNullOrWhiteSpace($s3EndpointUrl)) { throw 's3EndpointUrl is required for S3-compatible storage.' } + + $usedAwsTools = $false + if ($s3UseAwsTools) { + try { + $s3Client = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AccessKeyId -SecretAccessKey $s3SecretAccessKey -ForcePathStyle:$s3ForcePathStyle + $putReq = New-Object Amazon.S3.Model.PutObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName; FilePath = $encryptedFilePath } + $null = $s3Client.PutObject($putReq) + Write-Host "Encrypted file uploaded to S3-compatible bucket (AWS Tools): $blobName" + $tempDownloadPath = [System.IO.Path]::GetTempFileName() + $getReq = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName } + $getResp = $s3Client.GetObject($getReq) + $getResp.WriteResponseStreamToFile($tempDownloadPath, $true) + $getResp.Dispose() + $usedAwsTools = $true + } catch { + Write-Warning "AWS Tools path failed or not available. Falling back to native HTTP (SigV4). Details: $($_.Exception.Message)" + $usedAwsTools = $false + } + } + + if (-not $usedAwsTools) { + Invoke-S3PutFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -filePath $encryptedFilePath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle + Write-Host "Encrypted file uploaded to S3-compatible bucket: $blobName" + $tempDownloadPath = [System.IO.Path]::GetTempFileName() + Invoke-S3GetToFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -targetPath $tempDownloadPath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle + } + } else { + $sas = $sasToken + if ([string]::IsNullOrWhiteSpace($sas)) { throw 'sasToken is missing in settings.' } + $sas = $sas.Trim(); if (-not $sas.StartsWith('?')) { $sas = '?' + $sas } + try { Import-Module Az.Storage -ErrorAction Stop } catch {} + $storageContext = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sas + + $container = Get-AzStorageContainer -Name $containerName -Context $storageContext -ErrorAction SilentlyContinue + if (-not $container) { throw "Azure container '$containerName' not found or access denied." } + + Set-AzStorageBlobContent -File $encryptedFilePath -Container $containerName -Blob $blobName -Context $storageContext | Out-Null + Write-Host "Encrypted file uploaded to Azure Blob Storage: $blobName" + + $tempDownloadPath = [System.IO.Path]::GetTempFileName() + Get-AzStorageBlobContent -Blob $blobName -Container $containerName -Context $storageContext -Destination $tempDownloadPath -Force | Out-Null + } + + $downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath + + if ($localFileChecksum -eq $downloadedFileChecksum) { + Write-Host "The file was correctly uploaded. Checksum verified." + Remove-Item -Path $encryptedFilePath -Force + Remove-Item -Path $tempDownloadPath -Force + if ($storageProvider -ieq 'S3') { + Write-Host "Upload to S3-compatible storage completed and verified." + } else { + Write-Host "Upload to Azure Blob Storage completed and verified." + } + } else { + Write-Warning "Checksum verification failed. Encrypted file preserved for investigation: $encryptedFilePath" + if ($tempDownloadPath -and (Test-Path $tempDownloadPath)) { + Remove-Item -Path $tempDownloadPath -Force -ErrorAction SilentlyContinue + } + } + } finally { + # Always delete plaintext hashes and compressed archive regardless of outcome. + foreach ($f in @($exportPath, $compressedFilePath)) { + if ($f -and (Test-Path $f)) { + Remove-Item -Path $f -Force -ErrorAction SilentlyContinue + } + } } -} -else { - Write-Warning "Checksum verification failed. Keeping local artifacts for investigation: $exportPath, $compressedFilePath, $encryptedFilePath" - if (Test-Path $tempDownloadPath) { Remove-Item -Path $tempDownloadPath -Force } -} Write-Host "Script execution completed." } finally { diff --git a/Prepare-KHDBStorage.ps1 b/Prepare-KHDBStorage.ps1 index 755fc49..c9db7a4 100644 --- a/Prepare-KHDBStorage.ps1 +++ b/Prepare-KHDBStorage.ps1 @@ -7,7 +7,7 @@ ################################################## ## Project: Elysium ## ## File: Prepare-KHDBStorage.ps1 ## -## Version: 2.2.0 ## +## Version: 2.2.1 ## ## Support: support@cqre.net ## ################################################## @@ -100,44 +100,6 @@ function Remove-DirectoryContents { } } -function Read-KeyValueSettingsFile { - param([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 Get-SettingsValue { - param( - [hashtable]$Settings, - [string]$Key - ) - if (-not $Settings) { return $null } - if ($Settings.ContainsKey($Key)) { return $Settings[$Key] } - return $null -} - -function Get-FunctionDefinitionText { - param([Parameter(Mandatory = $true)][string]$Name) - $cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop - return $cmd.ScriptBlock.Ast.Extent.Text -} - function Merge-ShardsToFile { param( [psobject]$Manifest, @@ -176,27 +138,6 @@ function Get-NormalizedForwardPath { return $PathValue.Replace('\', '/').Trim('/') } -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 -} - function Upload-AzureBlob { param( [string]$Account, @@ -232,88 +173,6 @@ function Upload-AzureBlob { } } -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) { - $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 Invoke-S3HttpUpload { param( [string]$EndpointUrl, @@ -356,19 +215,6 @@ function Invoke-S3HttpUpload { } } -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" -} - function Split-KhdbIntoShards { param( [string]$Source, diff --git a/Settings.ps1 b/Settings.ps1 deleted file mode 100644 index 60fed6a..0000000 --- a/Settings.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -# Settings for Elysium Tool - -# General Settings -$Global:ToolRepositoryUrl = "https://example.com/git/elysium.git" - -# KHDB Update Settings -$Global:KnownHashesBaseUrl = "https://example.com/known-hashes/" -$Global:LocalKnownHashesPath = "C:\Elysium\known-hashes" - -# Test Weak AD Passwords Settings -$Global:DomainAdminUsernames = @{ - "Domain1" = "admin1"; - "Domain2" = "admin2"; - # Add more domains and usernames as needed -} -$Global:PdfReportPath = "C:\Elysium\Reports" - -# Extract and Send Hashes Settings -$Global:HashesExportPath = "C:\Elysium\Hashes" -$Global:ToolProviderUploadUrl = "https://upload.example.com/hashes" - -# Any additional settings... diff --git a/Test-WeakADPasswords.ps1 b/Test-WeakADPasswords.ps1 index 743a38c..ed8ccd7 100644 --- a/Test-WeakADPasswords.ps1 +++ b/Test-WeakADPasswords.ps1 @@ -8,7 +8,7 @@ ################################################## ## Project: Elysium ## ## File: Test-WeakADPasswords.ps1 ## -## Version: 2.2.0 ## +## Version: 2.2.1 ## ## Support: support@cqre.net ## ################################################## @@ -21,10 +21,13 @@ Weak AD password finder component of Elysium tool. This script will test the passwords of selected domain (defined in ElysiumSettings.txt) using DSInternals' Test-PasswordQuality cmdlet. It writes its output to a report file which is meant to be shared with the internal security team. The report now includes UPNs for each account mentioned. #> -# Enable verbose output $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +[string]$commonHelper = Join-Path -Path $PSScriptRoot -ChildPath 'Elysium.Common.ps1' +if (-not (Test-Path -LiteralPath $commonHelper)) { throw "Common helper not found at $commonHelper" } +. $commonHelper + $VerbosePreference = "SilentlyContinue" $scriptRoot = $PSScriptRoot @@ -92,7 +95,7 @@ function Invoke-UsageBeacon { if ($normalizedMethod -in @('POST', 'PUT')) { $payload = [ordered]@{ script = 'Test-WeakADPasswords' - version = '1.4.5' + version = '2.2.1' ranAtUtc = (Get-Date).ToUniversalTime().ToString('o') } if (-not [string]::IsNullOrWhiteSpace($InstanceId)) { @@ -124,32 +127,9 @@ $footer = "`r`n==== End of Report ====" Start-TestTranscript -BasePath $scriptRoot try { - # Import settings Write-Verbose "Loading settings..." - $ElysiumSettings = @{} - $settingsPath = Join-Path -Path $scriptRoot -ChildPath "ElysiumSettings.txt" - - # Ensure the settings file exists - if (-not (Test-Path $settingsPath)) { - Write-Error "Settings file not found at $settingsPath" - exit - } - - # Load settings from file - try { - Get-Content $settingsPath | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace($_) -and -not $_.StartsWith("#")) { - $keyValue = $_ -split '=', 2 - if ($keyValue.Count -eq 2) { - $ElysiumSettings[$keyValue[0].Trim()] = $keyValue[1].Trim() - } - } - } - Write-Verbose "Settings loaded successfully." - } catch { - Write-Error ("An error occurred while loading settings: {0}" -f $_.Exception.Message) - exit - } + $ElysiumSettings = Read-ElysiumSettings -ScriptRoot $scriptRoot + Write-Verbose "Settings loaded successfully." $usageBeaconUrl = $ElysiumSettings['UsageBeaconUrl'] $usageBeaconMethod = $ElysiumSettings['UsageBeaconMethod'] @@ -561,104 +541,6 @@ function Resolve-DSInternalsWeakHashFile { } } -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' - } - - # Collect caller SID + direct group SIDs so we can match ACEs below - $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 - } - - # Read the domain object's DACL via ADSI so we can use the provided credential - $acl = $null - try { - $de = New-Object System.DirectoryServices.DirectoryEntry( - "LDAP://$Server/$DomainDN", - $Credential.UserName, - $Credential.GetNetworkCredential().Password - ) - # Translate all trustees to SID form for consistent comparison - $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) -} - # Function to test for weak AD passwords function Test-WeakADPasswords { param ( diff --git a/Uninstall.ps1 b/Uninstall.ps1 index 1f37480..5261bca 100644 --- a/Uninstall.ps1 +++ b/Uninstall.ps1 @@ -7,7 +7,7 @@ ################################################## ## Project: Elysium ## ## File: Uninstall.ps1 ## -## Version: 2.2.0 ## +## Version: 2.2.1 ## ## Support: support@cqre.net ## ################################################## diff --git a/Update-KHDB.ps1 b/Update-KHDB.ps1 index d7a9033..1afdf0e 100644 --- a/Update-KHDB.ps1 +++ b/Update-KHDB.ps1 @@ -7,7 +7,7 @@ ################################################## ## Project: Elysium ## ## File: Update-KHDB.ps1 ## -## Version: 2.2.0 ## +## Version: 2.2.1 ## ## Support: support@cqre.net ## ################################################## @@ -50,21 +50,6 @@ function Stop-UpdateTranscript { try { Stop-Transcript | Out-Null } catch {} } -function Read-ElysiumSettings { - $settings = @{} - $settingsPath = Join-Path -Path $scriptRoot -ChildPath 'ElysiumSettings.txt' - if (-not (Test-Path $settingsPath)) { throw "Settings file not found at $settingsPath" } - Get-Content $settingsPath | ForEach-Object { - if ($_ -and -not $_.Trim().StartsWith('#')) { - $kv = $_ -split '=', 2 - if ($kv.Count -eq 2) { - $settings[$kv[0].Trim()] = $kv[1].Trim().Trim("'") - } - } - } - return $settings -} - function Get-InstallationPath([hashtable]$settings) { $p = $settings['InstallationPath'] if ([string]::IsNullOrWhiteSpace($p)) { return $scriptRoot } @@ -76,218 +61,10 @@ function New-HttpClient { Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue $client = [System.Net.Http.HttpClient]::new() $client.Timeout = [TimeSpan]::FromSeconds(600) - $client.DefaultRequestHeaders.UserAgent.ParseAdd('Elysium/2.1.1 (+Update-KHDB)') + $client.DefaultRequestHeaders.UserAgent.ParseAdd('Elysium/2.2.1 (+Update-KHDB)') return $client } -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('/') - $uriBuilder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob") - $uriBuilder.Query = $sas.TrimStart('?') - return $uriBuilder.Uri.AbsoluteUri -} - -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 Get-FunctionDefinitionText { - param([Parameter(Mandatory = $true)][string]$Name) - $cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop - return $cmd.ScriptBlock.Ast.Extent.Text -} - -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)) -} - -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) { - $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 Invoke-S3HttpDownloadWithRetry { - param( - [string]$EndpointUrl, - [string]$Bucket, - [string]$Key, - [string]$TargetPath, - [string]$Region, - [string]$AccessKeyId, - [string]$SecretAccessKey, - [bool]$ForcePathStyle, - [string]$Activity - ) - - Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue - [System.Net.Http.HttpClient]$client = [System.Net.Http.HttpClient]::new() - $retries = 5 - $delay = 2 - try { - for ($attempt = 0; $attempt -lt $retries; $attempt++) { - $request = $null - try { - $uri = BuildS3Uri -endpointUrl $EndpointUrl -bucket $Bucket -key $Key -forcePathStyle $ForcePathStyle - $payloadHash = (Get-HashHex (Get-Bytes '')) - $headers = BuildAuthHeaders -method 'GET' -uri $uri -region $Region -accessKey $AccessKeyId -secretKey $SecretAccessKey -payloadHash $payloadHash - $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri) - foreach ($kvp in $headers.GetEnumerator()) { - $request.Headers.TryAddWithoutValidation($kvp.Key, $kvp.Value) | Out-Null - } - - $response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() - $null = $response.EnsureSuccessStatusCode() - - $totalBytes = $response.Content.Headers.ContentLength - $stream = $response.Content.ReadAsStreamAsync().Result - $tmpPath = $TargetPath - $fs = [System.IO.File]::Create($tmpPath) - try { - $buffer = New-Object byte[] 8192 - $totalRead = 0 - while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { - $fs.Write($buffer, 0, $read) - $totalRead += $read - if ($totalBytes) { - $pct = ($totalRead * 100.0) / $totalBytes - Write-Progress -Activity $Activity -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct - } else { - Write-Progress -Activity $Activity -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0 - } - } - } finally { - $fs.Close() - $stream.Close() - } - - if ($response) { $response.Dispose() } - Write-Progress -Activity $Activity -Completed -Status 'Completed' - return - } catch { - if ($attempt -lt ($retries - 1)) { - Write-Warning "Download of '$Key' failed (attempt $($attempt + 1)/$retries): $($_.Exception.Message). Retrying in ${delay}s..." - Start-Sleep -Seconds $delay - $delay = [Math]::Min($delay * 2, 30) - } else { - throw - } - } finally { - if ($request) { $request.Dispose() } - } - } - } finally { - $client.Dispose() - } -} - function Invoke-DownloadWithRetry { param( [System.Net.Http.HttpClient]$Client, @@ -342,6 +119,79 @@ function Invoke-DownloadWithRetry { } } +function Invoke-S3HttpDownloadWithRetry { + param( + [string]$EndpointUrl, + [string]$Bucket, + [string]$Key, + [string]$TargetPath, + [string]$Region, + [string]$AccessKeyId, + [string]$SecretAccessKey, + [bool]$ForcePathStyle, + [string]$Activity + ) + + Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue + [System.Net.Http.HttpClient]$client = [System.Net.Http.HttpClient]::new() + $retries = 5 + $delay = 2 + try { + for ($attempt = 0; $attempt -lt $retries; $attempt++) { + $request = $null + try { + $uri = BuildS3Uri -endpointUrl $EndpointUrl -bucket $Bucket -key $Key -forcePathStyle $ForcePathStyle + $payloadHash = (Get-HashHex (Get-Bytes '')) + $headers = BuildAuthHeaders -method 'GET' -uri $uri -region $Region -accessKey $AccessKeyId -secretKey $SecretAccessKey -payloadHash $payloadHash + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri) + foreach ($kvp in $headers.GetEnumerator()) { + $request.Headers.TryAddWithoutValidation($kvp.Key, $kvp.Value) | Out-Null + } + + $response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + $null = $response.EnsureSuccessStatusCode() + + $totalBytes = $response.Content.Headers.ContentLength + $stream = $response.Content.ReadAsStreamAsync().Result + $fs = [System.IO.File]::Create($TargetPath) + try { + $buffer = New-Object byte[] 8192 + $totalRead = 0 + while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $fs.Write($buffer, 0, $read) + $totalRead += $read + if ($totalBytes) { + $pct = ($totalRead * 100.0) / $totalBytes + Write-Progress -Activity $Activity -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct + } else { + Write-Progress -Activity $Activity -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0 + } + } + } finally { + $fs.Close() + $stream.Close() + } + + if ($response) { $response.Dispose() } + Write-Progress -Activity $Activity -Completed -Status 'Completed' + return + } catch { + if ($attempt -lt ($retries - 1)) { + Write-Warning "Download of '$Key' failed (attempt $($attempt + 1)/$retries): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + $delay = [Math]::Min($delay * 2, 30) + } else { + throw + } + } finally { + if ($request) { $request.Dispose() } + } + } + } finally { + $client.Dispose() + } +} + function Get-FileSha256Lower { param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { throw "File not found: $Path" } @@ -387,19 +237,6 @@ function Get-RelativePath { return $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar) } -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" -} - function Load-Manifest { param([string]$Path) $raw = Get-Content -LiteralPath $Path -Encoding UTF8 -Raw @@ -542,7 +379,7 @@ function Update-KHDB { ) Start-UpdateTranscript -BasePath $scriptRoot try { - $settings = Read-ElysiumSettings + $settings = Read-ElysiumSettings -ScriptRoot $scriptRoot $installPath = Get-InstallationPath $settings Ensure-Directory $installPath @@ -558,10 +395,10 @@ function Update-KHDB { $parallelS3DownloadHelperList = @() if ($parallelDownloadsEnabled) { $parallelAzureDownloadHelpers = @{ - 'Build-BlobUri' = Get-FunctionDefinitionText 'Build-BlobUri' + 'Build-BlobUri' = Get-FunctionDefinitionText 'Build-BlobUri' 'Invoke-DownloadWithRetry' = Get-FunctionDefinitionText 'Invoke-DownloadWithRetry' - 'New-HttpClient' = Get-FunctionDefinitionText 'New-HttpClient' - 'Get-FileSha256Lower' = Get-FunctionDefinitionText 'Get-FileSha256Lower' + 'New-HttpClient' = Get-FunctionDefinitionText 'New-HttpClient' + 'Get-FileSha256Lower' = Get-FunctionDefinitionText 'Get-FileSha256Lower' } $parallelAzureDownloadHelperList = $parallelAzureDownloadHelpers.GetEnumerator() | ForEach-Object { [pscustomobject]@{ Name = $_.Key; Definition = $_.Value } @@ -727,14 +564,14 @@ function Update-KHDB { } } - if ($needsDownload) { - [void]$downloadQueue.Add([pscustomobject]@{ - Name = $name - Sha256 = $expectedHash - Size = $expectedSize - }) + if ($needsDownload) { + [void]$downloadQueue.Add([pscustomobject]@{ + Name = $name + Sha256 = $expectedHash + Size = $expectedSize + }) + } } - } if ($downloadQueue.Count -gt 0) { Write-Host ("{0} shard(s) require download or refresh." -f $downloadQueue.Count) @@ -763,12 +600,12 @@ function Update-KHDB { $storageClient = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AK -SecretAccessKey $s3SK -ForcePathStyle:$forcePathStyle } $storageHttpClient = @{ - Endpoint = $s3EndpointUrl - Bucket = $s3Bucket - Region = $s3Region - AccessKey = $s3AK - SecretKey = $s3SK - ForcePath = $forcePathStyle + Endpoint = $s3EndpointUrl + Bucket = $s3Bucket + Region = $s3Region + AccessKey = $s3AK + SecretKey = $s3SK + ForcePath = $forcePathStyle } } } else { @@ -846,11 +683,11 @@ function Update-KHDB { $downloadIndex = 0 foreach ($entry in $downloadQueue.ToArray()) { $downloadIndex++ - if ($null -eq $entry) { continue } - $name = [string]$entry.Name - if ([string]::IsNullOrWhiteSpace($name)) { - throw "Shard entry missing name: $(ConvertTo-Json $entry -Compress)" - } + if ($null -eq $entry) { continue } + $name = [string]$entry.Name + if ([string]::IsNullOrWhiteSpace($name)) { + throw "Shard entry missing name: $(ConvertTo-Json $entry -Compress)" + } $expectedHash = ([string]$entry.Sha256).ToLowerInvariant() $expectedSize = [long]$entry.Size @@ -862,7 +699,7 @@ function Update-KHDB { if ($isS3) { if ($storageClient) { try { - $request = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3Bucket; Key = $remoteKey } + $request = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3Bucket; Key = $remoteKey } $response = $storageClient.GetObject($request) try { $response.WriteResponseStreamToFile($stagingPath, $true) } finally { $response.Dispose() } } catch { diff --git a/Update-LithnetStore.ps1 b/Update-LithnetStore.ps1 index bf1bc80..1d016e3 100644 --- a/Update-LithnetStore.ps1 +++ b/Update-LithnetStore.ps1 @@ -7,7 +7,7 @@ ################################################## ## Project: Elysium ## ## File: Update-LithnetStore.ps1 ## -## Version: 2.2.0 ## +## Version: 2.2.1 ## ## Support: support@cqre.net ## ##################################################