Compare commits
32 Commits
v2.2.0
..
2d90656e5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d90656e5c | |||
| 5b761d8d56 | |||
| 80199ad7d6 | |||
| eea0ddf932 | |||
| 2f0ff9085a | |||
| 03ceec9d5e | |||
| 06e7607eff | |||
| 9bef6d50f5 | |||
| d155276366 | |||
| 867fb6427d | |||
| 1440b65b9a | |||
| 5691463bd3 | |||
| 91d6bcb216 | |||
| ec00518952 | |||
| 4740cd3e97 | |||
| 855be8de9c | |||
| ac3f30db1e | |||
| 65e451413e | |||
| 1d98b908c6 | |||
| 906bb52638 | |||
| af945f529e | |||
| 03aa72f999 | |||
| 10cbf0285d | |||
| fc91f0d6b0 | |||
| 6b2ae6c8b5 | |||
| 37d1a8d971 | |||
| 0175864e72 | |||
| 9496063b97 | |||
| 27a682a968 | |||
| 255cfe0a17 | |||
| 09c30f97e9 | |||
| 5127c2d096 |
@@ -0,0 +1,162 @@
|
||||
##################################################
|
||||
## ____ ___ ____ _____ _ _ _____ _____ ##
|
||||
## / ___/ _ \| _ \| ____| | \ | | ____|_ _| ##
|
||||
## | | | | | | |_) | _| | \| | _| | | ##
|
||||
## | |__| |_| | _ <| |___ _| |\ | |___ | | ##
|
||||
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
||||
## Move fast and fix things. ##
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Bump-Version.ps1 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
#Requires -Version 5.1
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bumps the unified Elysium version across all project files.
|
||||
|
||||
.DESCRIPTION
|
||||
Updates the centralized $ElysiumVersion variable, ASCII headers in all
|
||||
operational scripts, the settings template, and runtime references
|
||||
(User-Agent, usage beacon, etc.). Optionally stubs a new CHANGELOG entry.
|
||||
|
||||
.PARAMETER NewVersion
|
||||
The new version string to apply (e.g. 2.2.2).
|
||||
|
||||
.PARAMETER SkipChangelog
|
||||
Do not print a CHANGELOG entry stub.
|
||||
|
||||
.EXAMPLE
|
||||
.\Bump-Version.ps1 -NewVersion 2.2.2
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$NewVersion,
|
||||
|
||||
[switch]$SkipChangelog
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$scriptRoot = $PSScriptRoot
|
||||
if (-not $scriptRoot) { $scriptRoot = (Get-Location).Path }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate input
|
||||
# ---------------------------------------------------------------------------
|
||||
if ($NewVersion -notmatch '^\d+\.\d+\.\d+$') {
|
||||
throw "Version must be in semantic format X.Y.Z (e.g. 2.2.2). Got: '$NewVersion'"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Determine current version from Elysium.Common.ps1
|
||||
# ---------------------------------------------------------------------------
|
||||
$commonPath = Join-Path -Path $scriptRoot -ChildPath 'Elysium.Common.ps1'
|
||||
if (-not (Test-Path -LiteralPath $commonPath)) {
|
||||
throw "Elysium.Common.ps1 not found at $commonPath"
|
||||
}
|
||||
|
||||
$commonContent = Get-Content -LiteralPath $commonPath -Raw
|
||||
$currentVersionMatch = [regex]::Match($commonContent, "\`$script:ElysiumVersion\s*=\s*'([^']+)'")
|
||||
if (-not $currentVersionMatch.Success) {
|
||||
throw "Could not determine current version from Elysium.Common.ps1"
|
||||
}
|
||||
$oldVersion = $currentVersionMatch.Groups[1].Value
|
||||
|
||||
if ($oldVersion -eq $NewVersion) {
|
||||
Write-Warning "Current version is already $NewVersion. Nothing to do."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Bumping Elysium from $oldVersion -> $NewVersion"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: replace in file
|
||||
# ---------------------------------------------------------------------------
|
||||
function Edit-FileVersion {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Old,
|
||||
[string]$New
|
||||
)
|
||||
$content = Get-Content -LiteralPath $Path -Raw
|
||||
$newContent = $content.Replace($Old, $New)
|
||||
if ($newContent -eq $content) {
|
||||
Write-Verbose " No changes in $(Split-Path -Leaf $Path)"
|
||||
} else {
|
||||
Set-Content -LiteralPath $Path -Value $newContent -NoNewline -Encoding UTF8
|
||||
Write-Host " Updated $(Split-Path -Leaf $Path)"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Central version variable
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host "`n[1/4] Updating centralized version variable..."
|
||||
Edit-FileVersion -Path $commonPath -Old "`$script:ElysiumVersion = '$oldVersion'" -New "`$script:ElysiumVersion = '$NewVersion'"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. ASCII headers in scripts and templates
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host "`n[2/4] Updating script headers..."
|
||||
$headerTargets = Get-ChildItem -Path $scriptRoot -File | Where-Object {
|
||||
$_.Extension -in @('.ps1', '.py') -or $_.Name -eq 'ElysiumSettings.txt.sample'
|
||||
}
|
||||
foreach ($file in $headerTargets) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw
|
||||
# The header pattern: ## Version: X.Y.Z ##
|
||||
$pattern = "## Version:\s+$([regex]::Escape($oldVersion))\s+##"
|
||||
$replacement = "## Version: $NewVersion ##"
|
||||
$newContent = [regex]::Replace($content, $pattern, $replacement)
|
||||
if ($newContent -ne $content) {
|
||||
Set-Content -LiteralPath $file.FullName -Value $newContent -NoNewline -Encoding UTF8
|
||||
Write-Host " Updated $($file.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Runtime references (safety net)
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host "`n[3/4] Updating runtime version references..."
|
||||
$runtimeTargets = Get-ChildItem -Path $scriptRoot -Filter '*.ps1' -File
|
||||
foreach ($file in $runtimeTargets) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw
|
||||
$newContent = $content
|
||||
# User-Agent patterns: 'Elysium/2.2.1 (+...)' or "Elysium/2.2.1 (+...)"
|
||||
$newContent = [regex]::Replace($newContent,
|
||||
"Elysium/$([regex]::Escape($oldVersion))",
|
||||
"Elysium/$NewVersion")
|
||||
# Literal string assignments: version = '2.2.1'
|
||||
$newContent = [regex]::Replace($newContent,
|
||||
"version\s*=\s*'$([regex]::Escape($oldVersion))'",
|
||||
"version = '$NewVersion'")
|
||||
if ($newContent -ne $content) {
|
||||
Set-Content -LiteralPath $file.FullName -Value $newContent -NoNewline -Encoding UTF8
|
||||
Write-Host " Updated runtime refs in $($file.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. CHANGELOG stub
|
||||
# ---------------------------------------------------------------------------
|
||||
if (-not $SkipChangelog) {
|
||||
Write-Host "`n[4/4] CHANGELOG entry stub (copy-paste ready):"
|
||||
$today = (Get-Date).ToString('yyyy-MM-dd')
|
||||
$stub = @"
|
||||
|
||||
---
|
||||
|
||||
## [$NewVersion] - $today
|
||||
|
||||
### Changed
|
||||
- (describe your change here)
|
||||
"@
|
||||
Write-Host $stub
|
||||
Write-Host "`nAppend the above to CHANGELOG.md, then commit and tag."
|
||||
}
|
||||
|
||||
Write-Host "`nDone. Review the changes with: git diff --stat"
|
||||
+124
@@ -6,6 +6,130 @@ Starting with **v2.2.0**, Elysium uses a **unified project version**. All script
|
||||
|
||||
---
|
||||
|
||||
## [2.4.6] — 2026-07-29
|
||||
|
||||
### Fixed
|
||||
- `Uninstall.ps1` used `Get-Location` (the caller's working directory) instead of `$PSScriptRoot`, so running it from any CWD other than the install folder recursively force-deleted the wrong directory. The accompanying self-delete workaround was also broken (a `-Exclude` that could never match, a deferred `Start-Process` command line that couldn't bind its argument) and has been replaced with a plain recursive delete, since PowerShell holds no open handle on a script it has already read into memory.
|
||||
- `Elysium.ps1` echoed the AES export passphrase to the console (`Read-Host` without `-AsSecureString`) and stored it in plaintext in `HKCU\Environment`. It's now DPAPI-protected via `ConvertFrom-SecureString` and never appears in plaintext at rest; `Extract-NTHashes.ps1` decrypts it back only in memory right before use.
|
||||
- `Test-ReplicationPermissions` resolved the caller's SID/tokenGroups by stripping a `DOMAIN\` prefix from the credential username, so a UPN-formatted credential (`user@domain.tld`) had nothing to strip, the AD lookup threw, and the entire DCSync ACL pre-check was silently skipped. Now handles `DOMAIN\user`, `user@domain.tld`, and bare `sAMAccountName` correctly, and also disposes the `DirectoryEntry`/ADSI objects it creates.
|
||||
- `Elysium.ps1`'s main menu had no `catch` around the switch statement, so any error re-thrown by a sub-script (bad credentials, unreachable DC, etc.) killed the whole orchestrator instead of returning to the menu.
|
||||
- `Extract-NTHashes.ps1`: the temp directory holding plaintext NTLM hashes (before AES encryption) now has its ACL restricted to the current user; a checksum mismatch after upload now deletes the corrupt remote blob instead of leaving it live under its normal name; the plaintext export is now pinned to UTF-8 instead of depending on the PowerShell host; `Protect-FileWithAES`'s output format changed from `ELY1` to `ELY2`, adding an HMAC-SHA256 trailer (encrypt-then-MAC) so a wrong passphrase or tampered/corrupted ciphertext is detected instead of silently decrypting to garbage - **this is a breaking format change for any external decryption tooling**, see the README for the new layout.
|
||||
- `Update-KHDB.ps1`: `Validate-Manifest` hardcoded `shardSize` to exactly `2`, rejecting manifests produced with any other value from `Prepare-KHDBStorage.ps1`'s supported 1-8 range; KHDB backups (`khdb.txt.bak-*`) accumulated forever with no retention policy (now capped at the 5 most recent); `Invoke-DownloadWithRetry` never disposed the `HttpResponseMessage` it got from each shard download.
|
||||
- `Prepare-KHDBStorage.ps1`: `Split-KhdbIntoShards`'s fast path for plain 32-hex-char lines wrote straight through with no deduplication (unlike the slow path's adjacent-duplicate merge); fixing that surfaced a second bug where the valid-entry counter was a plain scalar mutated inside a scriptblock invoked via the call operator (`&`), which runs in its own child scope, so the increments were silently discarded and `TotalEntries`/checkpoint `validEntries`/live progress were all wrong on every run (moved onto the existing `$meta` hashtable, which mutates by reference). Also rejects manifest shard names (`-UploadOnly` mode) that resolve outside the shard root, closing a path-traversal gap for a tampered local `manifest.json`.
|
||||
- `Test-WeakADPasswords.ps1`: report generation piped results through `Out-String` without `-Width`, so a long `SamAccountName` could be truncated by the default formatter width and silently drop its `UPN:` annotation in the report.
|
||||
|
||||
## [2.4.5] — 2026-07-29
|
||||
|
||||
### Fixed
|
||||
- `Test-ReplicationPermissions` now detects explicit **Deny** ACEs on the replication extended rights, not just missing Allow grants. Previously the pre-flight check only scanned `Allow` ACEs, so an explicit Deny (common in hardening baselines that Deny a broad group like `Everyone`/`Domain Users` the replication rights and Allow only named DCSync accounts) was invisible to the check: it reported "verified" while `Get-ADReplAccount` still failed with "Replication access was denied". The check now flags exactly which right is blocked and by which identity's Deny ACE.
|
||||
|
||||
### Changed
|
||||
- README *Common errors* section expanded with a dedicated troubleshooting flow for "pre-flight passed but DCSync still denied" (RODC target, unconverged ACL replication, cross-domain group scope, and how to get a definitive answer via Event ID 4662 auditing).
|
||||
|
||||
---
|
||||
|
||||
## [2.4.4] — 2026-06-15
|
||||
|
||||
### Fixed
|
||||
- `Test-ReplicationPermissions` now checks **both** the domain NC (`DC=…`) and the schema NC (`CN=Schema,CN=Configuration,DC=…`) for the required DCSync extended rights. DSInternals 7.0 changed schema fetching from LDAP to DRS (`GetNCChanges`), so the schema NC now requires its own ACL entry. Previously the pre-flight check passed (domain NC rights present) while `Get-ADReplAccount` immediately failed at `FetchSchema()` with "Replication access was denied".
|
||||
- The `Replication access was denied` catch block in `Test-WeakADPasswords` now emits a structured, actionable error message that names the exact DNs to target and explains the DSInternals 7.0 schema NC change, replacing the previous generic "ensure this account has replication rights on the domain" message.
|
||||
- Diagnostic dump (`dcsync-diag-*.txt`) now includes a `SchemaDN` field so the schema NC path is immediately visible when triaging a dump.
|
||||
|
||||
### Changed
|
||||
- Least-privilege requirement updated: the DCSync service account now needs the three replication extended rights on **both** the domain NC *and* `CN=Configuration,DC=…` (which covers the schema NC via inheritance). See *Least privileges* in the README for delegation steps.
|
||||
|
||||
---
|
||||
|
||||
## [2.4.3] — 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- Replaced the `DirectoryEntry` + `RefreshCache` tokenGroups retrieval in `Test-ReplicationPermissions` with `Get-ADUser -Properties tokenGroups`. The previous `DirectoryEntry` approach was broken by the v2.4.1 URI-escaping "fix" (`EscapeDataString` produces percent-encoded paths that ADSI `DirectoryEntry` cannot parse, causing "invalid dn syntax" errors).
|
||||
- Removed `EscapeDataString` from the ACL-reading `DirectoryEntry` path in `Test-ReplicationPermissions` as well, since `DirectoryEntry` expects raw LDAP path syntax, not URI encoding.
|
||||
|
||||
---
|
||||
|
||||
## [2.4.2] — 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- Replaced UTF-8 em-dashes (`\u2014`) in `Elysium.Common.ps1` and `Bump-Version.ps1` with ASCII hyphens. On Windows PowerShell without a UTF-8 BOM, the three-byte em-dash sequence was misinterpreted as containing a quote character, causing cascading parse errors (unexpected token, missing closing `)`/`}`/`catch`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## [2.4.1] — 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- `Test-ReplicationPermissions` and `Test-DCClockSkew` now URI-escape Distinguished Names via `[System.Uri]::EscapeDataString` before embedding them in `DirectoryEntry` LDAP URLs. DNs containing `/`, `#`, or other reserved characters previously caused URL mis-parsing and constructor failures.
|
||||
|
||||
---
|
||||
|
||||
## [2.4.0] — 2026-06-09
|
||||
|
||||
### Added
|
||||
- **DC clock skew pre-flight check** (`Test-DCClockSkew` in `Elysium.Common.ps1`): compares the local machine clock against the target DC's `RootDSE.currentTime` before attempting DCSync. Warns if skew exceeds 300s (Kerberos hard limit) or 60s (approaching limit), and provides the `w32tm /resync /force` remediation command.
|
||||
- **SDProp protection warning** in `Test-ReplicationPermissions`: detects `adminCount=1` on the service account and warns that SDProp runs every 60 minutes and may silently revert replication rights or group memberships.
|
||||
- **Protected Users group warning** in `Test-ReplicationPermissions`: detects membership in the Protected Users group (RID 525) and warns that it restricts Kerberos delegation and RC4 authentication required by DSInternals for DRS replication.
|
||||
|
||||
### Fixed
|
||||
- DSInternals auto-update flow now uses `Install-Module -Force -AllowClobber` instead of `Update-Module` to avoid a PowerShellGet bug where null `PublishedDate` metadata causes "cannot convert null to type system.datetime".
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] — 2026-06-09
|
||||
|
||||
### Added
|
||||
- `Test-WeakADPasswords.ps1` now checks the installed DSInternals version at startup:
|
||||
- **v6.2** (unsigned) is flagged with a warning explaining that unsigned native DLLs are blocked and replication will fail. Remediation: `Update-Module DSInternals`.
|
||||
- **Below v7.0** triggers an interactive prompt offering to run `Update-Module DSInternals -Force` automatically. If accepted, the script updates the module and exits cleanly so the operator can re-run with the new version loaded.
|
||||
- v7.0+ is required because it fixes intermittent CRC errors mid-replication and `Test-PasswordQuality` result truncation bugs.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.5] — 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- The DSInternals `Zone.Identifier` block error message (added in v2.2.4) now dynamically resolves the actual DSInternals module path via `Get-Module` instead of hardcoding `$env:ProgramFiles\WindowsPowerShell\DSInternals`. The `Unblock-File` command in the error now points to the correct installation directory.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.4] — 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now skips `InheritOnly` ACEs when evaluating replication rights. An ACE marked `InheritOnly` applies only to child objects, not the domain root itself, so it does not grant the required extended rights for DCSync on the domain object.
|
||||
- `Import-CompatModule` (in `Test-WeakADPasswords.ps1`) now detects DSInternals being blocked by Windows `Zone.Identifier` (alternate data stream from internet download) and throws a clear, actionable error with the exact `Unblock-File` command to run. Previously this surfaced as an opaque non-FIPS warning.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.3] — 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now correctly recognizes `GenericAll` and blanket `ExtendedRight` (empty ObjectType) ACEs as satisfying replication permission requirements. Previously, only exact GUID-matched ExtendedRight ACEs were detected, causing false negatives when rights were granted via broader permissions.
|
||||
- Improved error diagnostics: the missing-rights message now indicates whether an ACE for the specific right exists on the domain object but is not assigned to the caller, versus no ACE existing at all.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.2] — 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now resolves the caller's **effective token SIDs** via the `tokenGroups` constructed attribute instead of walking `MemberOf` directly. This correctly accounts for nested group memberships and avoids false-positive "missing permissions" errors when the account is entitled through nested groups.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
$script:ElysiumVersion = '2.4.6'
|
||||
|
||||
function Invoke-RestartWithExecutable {
|
||||
param(
|
||||
[string]$ExecutablePath,
|
||||
@@ -68,3 +70,443 @@ 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
|
||||
)
|
||||
|
||||
$allThreeRights = [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'
|
||||
}
|
||||
|
||||
# DSInternals 7.0 fetches the AD schema via DRS (GetNCChanges) before replicating accounts.
|
||||
# The schema NC has its own ACL - rights on the domain NC do not cover it.
|
||||
# Older DSInternals read schema via LDAP (no special rights needed); v7.0 switched to DRS.
|
||||
$schemaDN = "CN=Schema,CN=Configuration,$DomainDN"
|
||||
|
||||
$ncsToCheck = [ordered]@{
|
||||
$DomainDN = $allThreeRights
|
||||
$schemaDN = [ordered]@{
|
||||
'Replicating Directory Changes' = [guid]'1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
|
||||
}
|
||||
}
|
||||
|
||||
$callerSids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
try {
|
||||
# Get-ADUser -Identity accepts a DN, GUID, SID, or sAMAccountName - but NOT a UPN. A
|
||||
# UPN-formatted credential (user@domain.tld) has no backslash, so naively stripping a
|
||||
# 'DOMAIN\' prefix left the full UPN in place, -Identity threw, and this whole pre-check
|
||||
# was silently skipped (caught below) regardless of how the username was typed.
|
||||
$rawUserName = $Credential.UserName
|
||||
if ($rawUserName -match '^[^\\]+\\(.+)$') {
|
||||
$adUser = Get-ADUser -Identity $Matches[1] -Server $Server -Credential $Credential `
|
||||
-Properties SID, DistinguishedName, adminCount -ErrorAction Stop
|
||||
} elseif ($rawUserName -match '@') {
|
||||
$adUser = Get-ADUser -Filter "UserPrincipalName -eq '$rawUserName'" -Server $Server -Credential $Credential `
|
||||
-Properties SID, DistinguishedName, adminCount -ErrorAction Stop | Select-Object -First 1
|
||||
if (-not $adUser) { throw "No AD user found with UserPrincipalName '$rawUserName'." }
|
||||
} else {
|
||||
$adUser = Get-ADUser -Identity $rawUserName -Server $Server -Credential $Credential `
|
||||
-Properties SID, DistinguishedName, adminCount -ErrorAction Stop
|
||||
}
|
||||
[void]$callerSids.Add($adUser.SID.Value)
|
||||
|
||||
# tokenGroups is a constructed attribute containing all SIDs in the user's token,
|
||||
# including nested group memberships - more reliable than walking MemberOf recursively.
|
||||
# Look up by DistinguishedName (unambiguous) rather than re-deriving the username format.
|
||||
$adUserWithTokenGroups = Get-ADUser -Identity $adUser.DistinguishedName -Server $Server -Credential $Credential `
|
||||
-Properties tokenGroups -ErrorAction Stop
|
||||
foreach ($sidBytes in $adUserWithTokenGroups.tokenGroups) {
|
||||
$sid = New-Object System.Security.Principal.SecurityIdentifier(@([byte[]]$sidBytes), 0)
|
||||
[void]$callerSids.Add($sid.Value)
|
||||
}
|
||||
|
||||
# adminCount=1 means SDProp is managing this account; it runs every 60 min and can
|
||||
# silently revert replication rights or group memberships granted to the account
|
||||
if ($adUser.adminCount -eq 1) {
|
||||
Write-Warning ("Account '{0}' has adminCount=1 (SDProp-protected). It is or was a member of a privileged group. SDProp runs every 60 minutes and may silently revert replication rights or group memberships on this account." -f $Credential.UserName)
|
||||
}
|
||||
|
||||
# Protected Users group (RID 525) blocks the Kerberos mechanisms DSInternals uses for DRS
|
||||
$domainSidStr = $adUser.SID.Value.Substring(0, $adUser.SID.Value.LastIndexOf('-'))
|
||||
$protectedUsersSid = "$domainSidStr-525"
|
||||
if ($callerSids.Contains($protectedUsersSid)) {
|
||||
Write-Warning ("Account '{0}' is a member of Protected Users. This group restricts Kerberos delegation and RC4 authentication that DSInternals requires for DRS replication - access will be denied regardless of assigned rights." -f $Credential.UserName)
|
||||
}
|
||||
} catch {
|
||||
Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
||||
return
|
||||
}
|
||||
|
||||
$allMissingLines = @()
|
||||
|
||||
foreach ($ncEntry in $ncsToCheck.GetEnumerator()) {
|
||||
$ncDN = $ncEntry.Key
|
||||
$rightsToCheck = $ncEntry.Value
|
||||
|
||||
$acl = $null
|
||||
$de = $null
|
||||
try {
|
||||
$de = New-Object System.DirectoryServices.DirectoryEntry(
|
||||
"LDAP://$Server/$ncDN",
|
||||
$Credential.UserName,
|
||||
$Credential.GetNetworkCredential().Password
|
||||
)
|
||||
$acl = $de.ObjectSecurity.GetAccessRules(
|
||||
$true, $true, [System.Security.Principal.SecurityIdentifier])
|
||||
} catch {
|
||||
Write-Warning ("Could not read ACL on '$ncDN' for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
||||
continue
|
||||
} finally {
|
||||
if ($de) { $de.Dispose() }
|
||||
}
|
||||
|
||||
foreach ($rightName in $rightsToCheck.Keys) {
|
||||
$guid = $rightsToCheck[$rightName]
|
||||
$granted = $false
|
||||
$aceExistsForGuid = $false
|
||||
$denyIdentity = $null
|
||||
foreach ($ace in $acl) {
|
||||
# InheritOnly ACEs apply to child objects only - the NC root itself is not covered
|
||||
if ([bool]($ace.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly)) { continue }
|
||||
$rights = $ace.ActiveDirectoryRights
|
||||
$hasExtended = [bool]($rights -band [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight)
|
||||
$hasGenericAll = [bool]($rights -band [System.DirectoryServices.ActiveDirectoryRights]::GenericAll)
|
||||
# Match: exact GUID, OR ExtendedRight with empty ObjectType (all extended rights), OR GenericAll
|
||||
$isMatch = $hasGenericAll `
|
||||
-or ($hasExtended -and $ace.ObjectType -eq [guid]::Empty) `
|
||||
-or ($hasExtended -and $ace.ObjectType -eq $guid)
|
||||
if (-not $isMatch) { continue }
|
||||
if (-not $callerSids.Contains($ace.IdentityReference.Value)) { continue }
|
||||
|
||||
if ($ace.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny) {
|
||||
# Explicit Deny ACEs are evaluated before Allow ACEs in a canonical ACL and win
|
||||
# regardless of any Allow found elsewhere. A check that only scans Allow ACEs would
|
||||
# falsely report the right as granted while the actual DRS call is still denied.
|
||||
$denyIdentity = $ace.IdentityReference.Value
|
||||
continue
|
||||
}
|
||||
|
||||
if ($ace.ObjectType -eq $guid) { $aceExistsForGuid = $true }
|
||||
$granted = $true
|
||||
}
|
||||
if ($denyIdentity) {
|
||||
$allMissingLines += "[on $ncDN] $rightName (DENIED by explicit Deny ACE for '$denyIdentity' - this overrides any Allow grant)"
|
||||
} elseif (-not $granted) {
|
||||
$hint = if ($aceExistsForGuid) {
|
||||
' (ACE exists but not assigned to this account or any of its groups)'
|
||||
} else {
|
||||
' (no ACE found for this right on this object)'
|
||||
}
|
||||
$allMissingLines += "[on $ncDN] $rightName$hint"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($allMissingLines.Count -gt 0) {
|
||||
$schemaNote = ''
|
||||
if ($allMissingLines | Where-Object { $_ -match [regex]::Escape($schemaDN) }) {
|
||||
$schemaNote = ("`n`nNOTE: DSInternals 7.0 fetches the AD schema via DRS before replicating accounts." +
|
||||
" Grant 'Replicating Directory Changes' on CN=Configuration,$DomainDN" +
|
||||
" (covers Schema NC via inheritance) in addition to the domain NC rights.")
|
||||
}
|
||||
$denyNote = ''
|
||||
if ($allMissingLines | Where-Object { $_ -match 'DENIED by explicit Deny ACE' }) {
|
||||
$denyNote = ("`n`nNOTE: at least one right is blocked by an explicit Deny ACE, not a missing grant." +
|
||||
" Find and remove/scope it: Advanced Security on the NC object > look for a Deny entry" +
|
||||
" covering 'Replicating Directory Changes*' that matches this account or one of its groups" +
|
||||
" (common with hardening baselines that Deny a broad group like Everyone/Domain Users).")
|
||||
}
|
||||
throw ("Account '{0}' failed replication permission check:`n - {1}{2}{3}" -f `
|
||||
$Credential.UserName, ($allMissingLines -join "`n - "), $schemaNote, $denyNote)
|
||||
}
|
||||
|
||||
Write-Host ("[+] Replication permissions verified for '{0}' on domain NC and schema NC." -f $Credential.UserName)
|
||||
}
|
||||
|
||||
function Test-DCClockSkew {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Server,
|
||||
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
||||
)
|
||||
$rootDse = $null
|
||||
try {
|
||||
$rootDse = New-Object System.DirectoryServices.DirectoryEntry(
|
||||
"LDAP://$Server/RootDSE",
|
||||
$Credential.UserName,
|
||||
$Credential.GetNetworkCredential().Password
|
||||
)
|
||||
$dcTimeStr = $rootDse.Properties['currentTime'][0]
|
||||
$dcTime = [datetime]::ParseExact(
|
||||
$dcTimeStr, 'yyyyMMddHHmmss.0Z',
|
||||
[System.Globalization.CultureInfo]::InvariantCulture,
|
||||
[System.Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime()
|
||||
$skewSeconds = [Math]::Abs(([datetime]::UtcNow - $dcTime).TotalSeconds)
|
||||
if ($skewSeconds -gt 300) {
|
||||
Write-Warning ("Clock skew of {0:N0}s with '{1}' exceeds Kerberos limit of 300s - authentication will fail. Sync the clock: w32tm /resync /force" -f $skewSeconds, $Server)
|
||||
} elseif ($skewSeconds -gt 60) {
|
||||
Write-Warning ("Clock skew of {0:N0}s detected with '{1}'. Kerberos allows up to 300s - approaching the limit." -f $skewSeconds, $Server)
|
||||
} else {
|
||||
Write-Host ("[+] Clock skew with '{0}': {1:N0}s (OK)." -f $Server, $skewSeconds)
|
||||
}
|
||||
} catch {
|
||||
Write-Warning ("Could not check clock skew against '{0}': {1}" -f $Server, $_.Exception.Message)
|
||||
} finally {
|
||||
if ($rootDse) { $rootDse.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
+23
-12
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Elysium.ps1 ##
|
||||
## Version: 2.2.0 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -39,20 +39,27 @@ if (-Not (Test-Path $settingsFilePath)) {
|
||||
Write-Host "ElysiumSettings.txt found."
|
||||
}
|
||||
|
||||
# Attempt to retrieve the passphrase from the environment variable
|
||||
$passphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
||||
# The passphrase is persisted DPAPI-protected (current user + machine) via ConvertFrom-SecureString,
|
||||
# never in plaintext, so it's only prompted for once per user/machine and never echoed to the console.
|
||||
$storedPassphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
||||
|
||||
if ([string]::IsNullOrEmpty($passphrase)) {
|
||||
Write-Host "No passphrase found in environment variables."
|
||||
$passphrase = Read-Host "Please enter your passphrase."
|
||||
# Here you could choose to set the environment variable or simply use the passphrase for the current session
|
||||
[System.Environment]::SetEnvironmentVariable("ELYSIUM_PASSPHRASE", $passphrase, [System.EnvironmentVariableTarget]::User)
|
||||
Write-Host "Passphrase stored as environment variable 'ELYSIUM_PASSPHRASE'."
|
||||
} else {
|
||||
Write-Host "Passphrase found in environment variables."
|
||||
$havePassphrase = $false
|
||||
if (-not [string]::IsNullOrEmpty($storedPassphrase)) {
|
||||
try {
|
||||
[void](ConvertTo-SecureString -String $storedPassphrase -ErrorAction Stop)
|
||||
$havePassphrase = $true
|
||||
Write-Host "Passphrase found in environment variables."
|
||||
} catch {
|
||||
Write-Warning "Stored passphrase is not in the expected protected format (leftover from an older Elysium version?). Re-enter it."
|
||||
}
|
||||
}
|
||||
|
||||
# Continue with the rest of your script...
|
||||
if (-not $havePassphrase) {
|
||||
Write-Host "No passphrase found in environment variables."
|
||||
$securePassphrase = Read-Host "Please enter your passphrase" -AsSecureString
|
||||
[System.Environment]::SetEnvironmentVariable("ELYSIUM_PASSPHRASE", (ConvertFrom-SecureString -SecureString $securePassphrase), [System.EnvironmentVariableTarget]::User)
|
||||
Write-Host "Passphrase stored (DPAPI-protected) as environment variable 'ELYSIUM_PASSPHRASE'."
|
||||
}
|
||||
|
||||
function Start-OrchestratorTranscript {
|
||||
param([string]$BasePath)
|
||||
@@ -102,6 +109,7 @@ try {
|
||||
do {
|
||||
Show-Menu
|
||||
$userSelection = Read-Host "Please make a selection"
|
||||
try {
|
||||
switch ($userSelection) {
|
||||
'1' {
|
||||
Write-Host "Downloading KHDB..."
|
||||
@@ -142,6 +150,9 @@ do {
|
||||
Write-Host "Invalid selection, please try again."
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Error ("An error occurred while running the selected option: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
pause
|
||||
} while ($userSelection -ne '6')
|
||||
} finally {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: ElysiumSettings.txt ##
|
||||
## Version: 2.2.0 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
|
||||
+258
-298
@@ -6,8 +6,8 @@
|
||||
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Extract-NTLMHashes.ps1 ##
|
||||
## Version: 2.2.0 ##
|
||||
## File: Extract-NTHashes.ps1 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -23,8 +23,21 @@ This script will connect to selected domain (defined in ElysiumSettings.txt) usi
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Ensure consistent UTF-8 output for files across PS5.1/PS7 (Out-File defaults to UTF-16LE on
|
||||
# Desktop edition and UTF-8 on Core, so the plaintext hash export's encoding would otherwise
|
||||
# depend purely on which PowerShell host happens to run it).
|
||||
try {
|
||||
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
|
||||
$OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
} catch { }
|
||||
|
||||
$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 +53,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 +82,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,12 +101,22 @@ 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 Invoke-S3DeleteFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
||||
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
||||
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
||||
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
||||
$client = [System.Net.Http.HttpClient]::new()
|
||||
try {
|
||||
$req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Delete, $uri)
|
||||
$hdrs = BuildAuthHeaders -method 'DELETE' -uri $uri -region $region -accessKey $ak -secretKey $sk -payloadHash $payloadHash
|
||||
$req.Headers.TryAddWithoutValidation('x-amz-date', $hdrs['x-amz-date']) | Out-Null
|
||||
$req.Headers.TryAddWithoutValidation('Authorization', $hdrs['Authorization']) | Out-Null
|
||||
$req.Headers.TryAddWithoutValidation('x-amz-content-sha256', $hdrs['x-amz-content-sha256']) | Out-Null
|
||||
$resp = $client.SendAsync($req).Result
|
||||
# S3 DELETE is idempotent and returns 204 even if the key never existed; anything else is a real failure.
|
||||
if (-not $resp.IsSuccessStatusCode) { throw "S3 DELETE failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase)" }
|
||||
} finally { if ($req) { $req.Dispose() }; $client.Dispose() }
|
||||
}
|
||||
|
||||
function Protect-FileWithAES {
|
||||
param (
|
||||
@@ -255,13 +130,19 @@ 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)
|
||||
|
||||
# Derive two independent keys from one PBKDF2 byte stream: the first 32 bytes for AES-256, the
|
||||
# next 32 for HMAC-SHA256 (Rfc2898DeriveBytes.GetBytes returns a continuous stream across
|
||||
# calls on the same instance, so these two ranges never overlap). CBC alone gives no integrity
|
||||
# check - tampered or corrupted ciphertext just decrypts to garbage (or throws an unhelpful
|
||||
# padding exception) instead of being detected. Encrypt-then-MAC over magic+salt+iv+ciphertext
|
||||
# (format 'ELY2') catches both. Older 'ELY1' files this script produced have no MAC.
|
||||
$kdf = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($Passphrase, $salt, 100000, [System.Security.Cryptography.HashAlgorithmName]::SHA256)
|
||||
$key = $kdf.GetBytes(32)
|
||||
$aesKey = $kdf.GetBytes(32)
|
||||
$hmacKey = $kdf.GetBytes(32)
|
||||
|
||||
$aes = [System.Security.Cryptography.Aes]::Create()
|
||||
$aes.KeySize = 256
|
||||
@@ -269,36 +150,26 @@ function Protect-FileWithAES {
|
||||
$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
|
||||
$aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aes.GenerateIV()
|
||||
|
||||
$iv = $aes.IV
|
||||
$encryptor = $aes.CreateEncryptor($key, $iv)
|
||||
|
||||
$fileStream = [System.IO.File]::Open($InputFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read)
|
||||
$outFileStream = [System.IO.File]::Create($OutputFile)
|
||||
|
||||
$encryptor = $aes.CreateEncryptor($aesKey, $iv)
|
||||
$hmac = [System.Security.Cryptography.HMACSHA256]::new($hmacKey)
|
||||
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)
|
||||
$outFileStream.Write($iv, 0, $iv.Length)
|
||||
$plainBytes = [System.IO.File]::ReadAllBytes($InputFile)
|
||||
$cipherBytes = $encryptor.TransformFinalBlock($plainBytes, 0, $plainBytes.Length)
|
||||
|
||||
$cryptoStream = New-Object System.Security.Cryptography.CryptoStream($outFileStream, $encryptor, [System.Security.Cryptography.CryptoStreamMode]::Write)
|
||||
try {
|
||||
$buffer = New-Object Byte[] 8192
|
||||
while (($read = $fileStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
||||
$cryptoStream.Write($buffer, 0, $read)
|
||||
}
|
||||
} finally {
|
||||
$cryptoStream.FlushFinalBlock()
|
||||
$cryptoStream.Close()
|
||||
}
|
||||
$magic = [System.Text.Encoding]::ASCII.GetBytes('ELY2')
|
||||
$header = $magic + $salt + $iv
|
||||
$mac = $hmac.ComputeHash($header + $cipherBytes)
|
||||
|
||||
[System.IO.File]::WriteAllBytes($OutputFile, ($header + $cipherBytes + $mac))
|
||||
} finally {
|
||||
$outFileStream.Close(); $fileStream.Close(); $aes.Dispose(); $rng.Dispose(); $kdf.Dispose()
|
||||
$encryptor.Dispose(); $hmac.Dispose(); $aes.Dispose(); $rng.Dispose(); $kdf.Dispose()
|
||||
}
|
||||
|
||||
Write-Host "File has been encrypted (PBKDF2+AES-256-CBC): $OutputFile"
|
||||
Write-Host "File has been encrypted (PBKDF2+AES-256-CBC+HMAC-SHA256): $OutputFile"
|
||||
}
|
||||
|
||||
function Get-FileChecksum {
|
||||
param (
|
||||
[string]$Path,
|
||||
@@ -309,142 +180,231 @@ 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' }
|
||||
|
||||
# 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 }
|
||||
|
||||
# Retrieve the DPAPI-protected passphrase from a user environment variable (see Elysium.ps1)
|
||||
$protectedPassphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
||||
if ([string]::IsNullOrWhiteSpace($protectedPassphrase)) { throw 'Passphrase not found in ELYSIUM_PASSPHRASE environment variable. Run Elysium.ps1 once to set it.' }
|
||||
try {
|
||||
$securePassphrase = ConvertTo-SecureString -String $protectedPassphrase -ErrorAction Stop
|
||||
} catch {
|
||||
throw "ELYSIUM_PASSPHRASE is not in the expected DPAPI-protected format (leftover from an older Elysium version?). Re-run Elysium.ps1 to re-enter it."
|
||||
}
|
||||
}
|
||||
$passphrase = [System.Net.NetworkCredential]::new('', $securePassphrase).Password
|
||||
|
||||
# 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]
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
|
||||
if (-not $selectedDomain) {
|
||||
Write-Error "Invalid selection."
|
||||
exit
|
||||
}
|
||||
$reportBase = Normalize-ReportPath -p $ElysiumSettings['ReportPathBase']
|
||||
if (-not (Test-Path $reportBase)) { New-Item -Path $reportBase -ItemType Directory -Force | Out-Null }
|
||||
|
||||
# Update script variables based on selected domain
|
||||
$domainController = $selectedDomain.DC
|
||||
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $($selectedDomain.Name)"
|
||||
|
||||
$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"
|
||||
|
||||
$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: $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
|
||||
try {
|
||||
# Plaintext NTLM hashes land in this directory before AES protection is applied below.
|
||||
# Strip inherited ACEs (e.g. a broad "Users" grant on the parent Temp folder) so only the
|
||||
# current user can read it while the finally block's cleanup hasn't run yet.
|
||||
$dirAcl = $tmpDir.GetAccessControl()
|
||||
$dirAcl.SetAccessRuleProtection($true, $false)
|
||||
$currentUserRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
[System.Security.Principal.WindowsIdentity]::GetCurrent().User,
|
||||
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
||||
$dirAcl.AddAccessRule($currentUserRule)
|
||||
$tmpDir.SetAccessControl($dirAcl)
|
||||
} catch {
|
||||
Write-Warning "Could not restrict ACL on temporary directory '$($tmpDir.FullName)': $($_.Exception.Message)"
|
||||
}
|
||||
$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
|
||||
}
|
||||
|
||||
# A checksum mismatch means the blob already sitting in remote storage under $blobName
|
||||
# is corrupt/incomplete. Leaving it live under its normal name would let a downstream
|
||||
# consumer silently fetch bad data, so remove it rather than only warning locally.
|
||||
try {
|
||||
if ($storageProvider -ieq 'S3') {
|
||||
if ($usedAwsTools -and $s3Client) {
|
||||
$delReq = New-Object Amazon.S3.Model.DeleteObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName }
|
||||
$null = $s3Client.DeleteObject($delReq)
|
||||
} else {
|
||||
Invoke-S3DeleteFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle
|
||||
}
|
||||
} else {
|
||||
Remove-AzStorageBlob -Blob $blobName -Container $containerName -Context $storageContext -Force -ErrorAction Stop
|
||||
}
|
||||
Write-Warning "Removed the mismatched blob '$blobName' from remote storage."
|
||||
} catch {
|
||||
Write-Warning "Could not remove the mismatched blob '$blobName' from remote storage - remove it manually: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
|
||||
+42
-168
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Prepare-KHDBStorage.ps1 ##
|
||||
## Version: 2.2.0 ##
|
||||
## Version: 2.4.6 ##
|
||||
## 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,
|
||||
@@ -395,14 +241,19 @@ function Split-KhdbIntoShards {
|
||||
|
||||
$shardStates = @{}
|
||||
$stats = @{}
|
||||
$total = 0L
|
||||
$resumeFilePosition = 0L
|
||||
|
||||
# ValidEntries lives on $meta (a hashtable, i.e. reference type) rather than as its own scalar
|
||||
# variable because $processHashLine below is invoked via the call operator (&), which runs in
|
||||
# its own child scope - a bare scalar '$total++' inside it would silently increment a local
|
||||
# shadow copy and never update the caller's variable. Mutating a property on a shared
|
||||
# hashtable/object works fine across that scope boundary.
|
||||
$meta = @{
|
||||
TotalLines = 0L
|
||||
InvalidLines = 0L
|
||||
SkippedLines = 0L
|
||||
LegacyLines = 0L
|
||||
ValidEntries = 0L
|
||||
InvalidSamples = New-Object System.Collections.Generic.List[string]
|
||||
}
|
||||
|
||||
@@ -425,7 +276,7 @@ function Split-KhdbIntoShards {
|
||||
if ($ResumeState.totalLines) { $meta.TotalLines = [long]$ResumeState.totalLines }
|
||||
if ($ResumeState.invalidLines) { $meta.InvalidLines = [long]$ResumeState.invalidLines }
|
||||
if ($ResumeState.skippedLines) { $meta.SkippedLines = [long]$ResumeState.skippedLines }
|
||||
if ($ResumeState.validEntries) { $total = [long]$ResumeState.validEntries }
|
||||
if ($ResumeState.validEntries) { $meta.ValidEntries = [long]$ResumeState.validEntries }
|
||||
if ($ResumeState.filePosition) { $resumeFilePosition = [long]$ResumeState.filePosition }
|
||||
}
|
||||
$plainReader = $null
|
||||
@@ -525,7 +376,7 @@ function Split-KhdbIntoShards {
|
||||
totalLines = [long]$meta.TotalLines
|
||||
invalidLines = [long]$meta.InvalidLines
|
||||
skippedLines = [long]$meta.SkippedLines
|
||||
validEntries = [long]$total
|
||||
validEntries = [long]$meta.ValidEntries
|
||||
shardStates = @()
|
||||
}
|
||||
foreach ($entry in ($shardStates.GetEnumerator() | Sort-Object Key)) {
|
||||
@@ -557,7 +408,7 @@ function Split-KhdbIntoShards {
|
||||
if ([string]::IsNullOrWhiteSpace($statusContext)) {
|
||||
$statusContext = if ($currentSource) { Split-Path -Leaf $currentSource } else { 'input' }
|
||||
}
|
||||
$status = "Processed {0:N0} hashes (+{1:N0} invalid, {2:N0} skipped, {3:N0} lines) [{4}]" -f $total, $meta.InvalidLines, $meta.SkippedLines, $meta.TotalLines, $statusContext
|
||||
$status = "Processed {0:N0} hashes (+{1:N0} invalid, {2:N0} skipped, {3:N0} lines) [{4}]" -f $meta.ValidEntries, $meta.InvalidLines, $meta.SkippedLines, $meta.TotalLines, $statusContext
|
||||
Write-Progress -Activity $ProgressActivity -Status $status -PercentComplete 0
|
||||
if ($EnableCheckpoint -and $plainReader) {
|
||||
$checkpointPosition = if ($plainBaseStream) { $plainBaseStream.Position } else { $plainReader.BaseStream.Position }
|
||||
@@ -581,12 +432,26 @@ function Split-KhdbIntoShards {
|
||||
return
|
||||
}
|
||||
|
||||
# Fast path for valid 32-char hex lines
|
||||
# Fast path for valid 32-char hex lines. Routes through the same pending-line dedup as the
|
||||
# slow path below (adjacent identical hashes are dropped) instead of writing straight
|
||||
# through - the source is expected to be pre-sorted, so only *adjacent* duplicates are
|
||||
# caught either way, but the fast path used to skip this entirely and write every
|
||||
# duplicate straight to the shard.
|
||||
if ($rawLine.Length -eq 32 -and $rawLine -match '^[0-9A-Fa-f]{32}$') {
|
||||
$prefixKey = $rawLine.Substring(0, $PrefixLength).ToLowerInvariant()
|
||||
$shardStates[$prefixKey].Writer.WriteLine($rawLine.ToUpperInvariant())
|
||||
$normalizedHash = $rawLine.ToUpperInvariant()
|
||||
$prefixKey = $normalizedHash.Substring(0, $PrefixLength).ToLowerInvariant()
|
||||
$state = $shardStates[$prefixKey]
|
||||
if ($state.PendingHash -ne $normalizedHash) {
|
||||
if ($state.PendingLine) {
|
||||
$state.Writer.WriteLine($state.PendingLine)
|
||||
$state.Count++
|
||||
$meta.ValidEntries++
|
||||
}
|
||||
$state.PendingLine = $normalizedHash
|
||||
$state.PendingHash = $normalizedHash
|
||||
$state.PendingCount = 0
|
||||
}
|
||||
$meta.TotalLines++
|
||||
$total++
|
||||
return
|
||||
}
|
||||
|
||||
@@ -671,7 +536,7 @@ function Split-KhdbIntoShards {
|
||||
if ($state.PendingLine) {
|
||||
$state.Writer.WriteLine($state.PendingLine)
|
||||
$state.Count++
|
||||
$total++
|
||||
$meta.ValidEntries++
|
||||
}
|
||||
$state.PendingLine = $normalizedLine
|
||||
$state.PendingHash = $normalizedHash
|
||||
@@ -805,7 +670,7 @@ function Split-KhdbIntoShards {
|
||||
if ($state.PendingLine) {
|
||||
$state.Writer.WriteLine($state.PendingLine)
|
||||
$state.Count++
|
||||
$total++
|
||||
$meta.ValidEntries++
|
||||
$state.PendingLine = $null
|
||||
}
|
||||
$state.Writer.Dispose()
|
||||
@@ -815,13 +680,13 @@ function Split-KhdbIntoShards {
|
||||
}
|
||||
}
|
||||
|
||||
if ($total -eq 0) { throw 'Source did not contain any valid hashes after processing.' }
|
||||
if ($meta.ValidEntries -eq 0) { throw 'Source did not contain any valid hashes after processing.' }
|
||||
if ($ShowProgress) {
|
||||
$status = "Processed {0:N0} hashes (+{1:N0} invalid, {2:N0} skipped, {3:N0} lines)" -f $total, $meta.InvalidLines, $meta.SkippedLines, $meta.TotalLines
|
||||
$status = "Processed {0:N0} hashes (+{1:N0} invalid, {2:N0} skipped, {3:N0} lines)" -f $meta.ValidEntries, $meta.InvalidLines, $meta.SkippedLines, $meta.TotalLines
|
||||
Write-Progress -Activity $ProgressActivity -Status $status -Completed
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
TotalEntries = [long]$total
|
||||
TotalEntries = [long]$meta.ValidEntries
|
||||
ShardStats = $stats
|
||||
TotalLines = [long]$meta.TotalLines
|
||||
InvalidLines = [long]$meta.InvalidLines
|
||||
@@ -1027,12 +892,21 @@ if ($UploadOnly) {
|
||||
|
||||
$manifestHash = (Get-FileHash -Path $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
$resolvedLocalShardRoot = [System.IO.Path]::GetFullPath($localShardRoot).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
|
||||
|
||||
$manifestShards = @()
|
||||
$totalSizeBytes = 0L
|
||||
foreach ($entry in ($manifestObject.shards | Sort-Object name)) {
|
||||
$name = [string]$entry.name
|
||||
if ([string]::IsNullOrWhiteSpace($name)) { continue }
|
||||
$localPath = Join-Path -Path $localShardRoot -ChildPath $name
|
||||
# Manifest shard names are attacker-controllable if manifest.json was tampered with (requires
|
||||
# prior local write access to the shard directory). Reject anything that resolves outside the
|
||||
# shard root instead of trusting Join-Path to keep '..'/rooted paths contained.
|
||||
$resolvedLocalPath = [System.IO.Path]::GetFullPath($localPath)
|
||||
if (-not $resolvedLocalPath.StartsWith($resolvedLocalShardRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Manifest shard name '$name' resolves outside the shard directory '$localShardRoot'."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $localPath)) {
|
||||
throw "Shard file '$name' listed in manifest was not found under '$localShardRoot'."
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ Sensitive operations are confined only to the dedicated host. In the third step,
|
||||
## Prerequisities
|
||||
* **Windows Host:** A Windows machine with PowerShell and DSInternals suite installed.
|
||||
* **Administrative Access:** Local admin privileges on the host for installation and updating.
|
||||
* **Domain Credentials:** For weak-password testing (option 2), an account with the three replication rights (`Replicating Directory Changes`, `Replicating Directory Changes All`, `Replicating Directory Changes In Filtered Set`) on the domain naming context; Domain Admin also works but is not required. Keep this account disabled and enable only when running tests.
|
||||
* **Domain Credentials:** For weak-password testing (option 2), an account with the three replication rights (`Replicating Directory Changes`, `Replicating Directory Changes All`, `Replicating Directory Changes In Filtered Set`) on **both** the domain naming context **and** `CN=Configuration,DC=…` (which covers the schema NC via inheritance). Domain Admin also works but is not required. See *Least privileges* below for exact delegation steps. Keep this account disabled and enable only when running tests.
|
||||
* **Network Requirements:** A stable connection to the domain controller in each tested AD domain and internet access (specific hostnames/IP addresses will be provided).
|
||||
|
||||
## Versioning and Releases
|
||||
@@ -58,20 +58,46 @@ The tool connects to the selected Domain Controller and compares accounts agains
|
||||
The KHDB file is consumed by DSInternals as a sorted hash list with one NT hash per line (for example `HASH`). Do not include `:count` suffixes in `khdb.txt`; the packaging and update scripts normalize legacy `HASH:count` input to the hash-only format automatically.
|
||||
|
||||
#### Least privileges for password-quality testing
|
||||
The DSInternals cmdlets (`Get-ADReplAccount`/`Test-PasswordQuality`) pull replicated password data, which requires DCSync-style rights. The account that runs option 2 does not have to be a Domain Admin if it has these permissions on the domain naming context:
|
||||
The DSInternals cmdlets (`Get-ADReplAccount`/`Test-PasswordQuality`) pull replicated password data using the MS-DRSR (DCSync) protocol. The account does not need to be a Domain Admin; delegate these three extended rights on **two** AD objects:
|
||||
|
||||
| Object | Why |
|
||||
|--------|-----|
|
||||
| Domain NC root — e.g. `DC=admin,DC=lan` | Required to replicate account password hashes |
|
||||
| Configuration NC root — e.g. `CN=Configuration,DC=admin,DC=lan` | Required by DSInternals 7.0+ to fetch the AD schema via DRS before replication; covers the schema NC (`CN=Schema,CN=Configuration,DC=…`) via inheritance |
|
||||
|
||||
Rights to delegate on both objects:
|
||||
- `Replicating Directory Changes`
|
||||
- `Replicating Directory Changes All`
|
||||
- `Replicating Directory Changes In Filtered Set` (needed on 2008 R2+ to read password hashes)
|
||||
- `Replicating Directory Changes In Filtered Set` (required on 2008 R2+ to read password hashes)
|
||||
|
||||
To delegate, enable Advanced Features in ADUC, right-click the domain, choose *Delegate Control…*, pick the service account, select *Create a custom task*, apply to *This object and all descendant objects*, and tick the three replication permissions above. Keep this account disabled and only activate it for scheduled tests.
|
||||
**To delegate in ADUC:** enable *Advanced Features*, right-click each object above, choose *Properties* > *Security* > *Advanced* > *Add*, select the service account, set *Applies to: This object only*, and tick the three rights. Repeat for both objects.
|
||||
|
||||
**To delegate via `dsacls`** (replace `DC=admin,DC=lan` and `DOMAIN\svc` as appropriate):
|
||||
```powershell
|
||||
foreach ($nc in @('DC=admin,DC=lan', 'CN=Configuration,DC=admin,DC=lan')) {
|
||||
dsacls $nc /I:T /G "DOMAIN\svc:CA;Replicating Directory Changes"
|
||||
dsacls $nc /I:T /G "DOMAIN\svc:CA;Replicating Directory Changes All"
|
||||
dsacls $nc /I:T /G "DOMAIN\svc:CA;Replicating Directory Changes In Filtered Set"
|
||||
}
|
||||
```
|
||||
|
||||
Keep the service account disabled and only activate it for scheduled tests.
|
||||
|
||||
#### Common errors
|
||||
- `The server has rejected the client credentials.` or `Credentials ... were rejected`:
|
||||
The supplied username/password is invalid for the selected domain controller, or the session is not running in the expected domain context. Re-run and provide valid domain credentials.
|
||||
- `Account '<user>' is missing the following replication permissions ...`:
|
||||
Starting with v2.2.0, the script pre-validates the three required replication extended rights against the domain object ACL before attempting DCSync. If this error appears, delegate the listed rights (see *Least privileges* above) and retry.
|
||||
- `Get-ADReplAccount: Access is denied`:
|
||||
Credentials are valid, but the account does not have the three replication permissions listed above. This error should now be rare because the pre-check catches most permission issues early; if it still occurs, verify the account is not restricted by an additional conditional access or Group Policy setting.
|
||||
- `Account '<user>' failed replication permission check ... (DENIED by explicit Deny ACE for '<sid>')`:
|
||||
The pre-flight check (v2.4.5+) also scans for explicit **Deny** ACEs on the replication extended rights, not just missing Allow grants. A Deny ACE — commonly added by hardening baselines that Deny a broad group (`Everyone`, `Domain Users`, `Authenticated Users`) the replication rights and Allow only named DCSync accounts — wins over any Allow, even one granted directly to this account. Open *Advanced Security* on the flagged NC object, find the Deny entry that matches this account or one of its groups, and either remove it or exclude the service account/its group from it.
|
||||
- `Replication access was denied` (from `Get-ADReplAccount`) **after the pre-flight check reported success**:
|
||||
This means the ACL looks correct from LDAP but the live DRS (`GetNCChanges`) call still denies access. Known causes, roughly in likelihood order:
|
||||
1. **Target DC is an RODC.** Read-only domain controllers enforce the Password Replication Policy and will refuse to originate a full DCSync of secrets for accounts outside their allowed replication list, regardless of ACL grants. Point `ElysiumSettings.txt` at a writable DC instead.
|
||||
2. **ACL change hasn't converged yet.** If the rights were just delegated on a different DC than the one configured for the test, wait for AD replication to catch up (or force it with `repadmin /syncall`) before retrying.
|
||||
3. **Explicit Deny ACE not caught by an older script version.** Update to v2.4.5+ so the pre-flight check surfaces it (see above) instead of only discovering it at DCSync time.
|
||||
4. **Account is in a cross-domain group** whose scope isn't visible in `tokenGroups` from the DC being queried (for example, a domain-local group in a different domain). Re-delegate directly to the account or to a universal group instead.
|
||||
|
||||
For a definitive answer straight from the DC: enable "Audit Directory Service Access" and add a SACL for `Replicating Directory Changes*` on the domain/schema NC, then check the DC's Security event log for Event ID 4662 on the next failed run — it names the exact object and right that were denied.
|
||||
- `Only FIPS certified cryptographic algorithms are enabled in .NET`:
|
||||
This warning comes from DSInternals under FIPS-enforced environments. Hash-quality operations that rely on MD5 may be limited.
|
||||
|
||||
@@ -82,6 +108,8 @@ If you want to know the script was executed without collecting telemetry, set a
|
||||
Run script Elysium.ps1 as an administrator and choose option 3 (Extract and Send Hashes).
|
||||
Domains are listed in configuration order, after which the script prompts for the replication-capable account password. With valid credentials, it extracts current NTLM hashes (no history) for active accounts, compresses the results, encrypts them with the configured passphrase, and uploads the payload to the configured storage (Azure Blob or S3-compatible). A checksum-verified round-trip download confirms the upload before local artifacts are removed.
|
||||
|
||||
**Encrypted export format (v2.4.5+, magic `ELY2`):** `4-byte magic 'ELY2' | 16-byte PBKDF2 salt | 16-byte AES IV | AES-256-CBC ciphertext | 32-byte HMAC-SHA256`. Both keys are derived from the configured passphrase via one PBKDF2-SHA256 (100,000 iterations) byte stream: the first 32 bytes are the AES key, the next 32 are the HMAC key. The HMAC covers `magic | salt | iv | ciphertext` (encrypt-then-MAC) so a decrypt tool must verify it *before* decrypting - CBC alone doesn't detect a wrong passphrase or corrupted/tampered ciphertext, it just produces garbage or an unhelpful padding exception. This is a breaking change from the older `ELY1` format (no HMAC trailer) produced before v2.4.5; any external decryption tooling on the air-gapped cracking machine needs updating to match.
|
||||
|
||||
### Update Lithnet Password Protection store
|
||||
Run script Elysium.ps1 as an administrator and choose option 5 (Update Lithnet Password Protection Store).
|
||||
Configure the target folder via `LithnetStorePath` in `ElysiumSettings.txt` (the location created with `Open-Store`). The script automatically imports the `khdb.txt` file unless you override/add additional NTLM hash lists in `LithnetHashSources` (comma or semicolon separated). You can also populate plaintext password lists (`LithnetPlaintextSources`) and banned-word files (`LithnetBannedWordSources`), or enable `LithnetSyncHibp=true` to seed the store directly from the Have I Been Pwned API (using `Sync-HashesFromHibp`). Behind the scenes the helper loads the `LithnetPasswordProtection` module, opens the store, runs [`Import-CompromisedPasswordHashes`](https://docs.lithnet.io/password-protection/advanced-help/powershell-reference/import-compromisedpasswordhashes)/`Import-CompromisedPasswords`/`Import-BannedWords` for each configured file, and then closes the store.
|
||||
|
||||
@@ -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...
|
||||
+99
-136
@@ -8,7 +8,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Test-WeakADPasswords.ps1 ##
|
||||
## Version: 2.2.0 ##
|
||||
## Version: 2.4.6 ##
|
||||
## 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 = $ElysiumVersion
|
||||
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']
|
||||
@@ -372,7 +352,14 @@ function Import-CompatModule {
|
||||
|
||||
$nonFipsErrors = @($importErrors | Where-Object { $_.Exception.Message -notmatch 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
|
||||
if ($nonFipsErrors.Count -gt 0) {
|
||||
Write-Warning ("DSInternals import reported non-fatal warning(s): {0}" -f $nonFipsErrors[0].Exception.Message)
|
||||
$nonFipsMsg = $nonFipsErrors[0].Exception.Message
|
||||
if ($nonFipsMsg -match 'Zone\.Identifier|alternate data stream') {
|
||||
$dsModule = Get-Module -Name DSInternals -ErrorAction SilentlyContinue
|
||||
if (-not $dsModule) { $dsModule = Get-Module -ListAvailable -Name DSInternals -ErrorAction SilentlyContinue | Select-Object -First 1 }
|
||||
$dsPath = if ($dsModule) { $dsModule.ModuleBase } else { '<DSInternals module path>' }
|
||||
throw ("DSInternals native DLL is blocked by Windows (Zone.Identifier). Run the following on the target machine and retry:`n Get-ChildItem -Path '$dsPath' -Recurse | Unblock-File")
|
||||
}
|
||||
Write-Warning ("DSInternals import reported non-fatal warning(s): {0}" -f $nonFipsMsg)
|
||||
}
|
||||
|
||||
Write-Verbose ("Imported module '{0}' (Core={1}, Windows={2})" -f $Name, $runningInPSCore, $onWindows)
|
||||
@@ -405,6 +392,28 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
# Version check: v6.2 was unsigned (blocks native DLLs, causes replication failures);
|
||||
# v7.0 fixes intermittent CRC errors mid-replication and Test-PasswordQuality result truncation.
|
||||
$dsInternalsVersion = (Get-Module -Name DSInternals).Version
|
||||
$minimumVersion = [version]'7.0'
|
||||
$unsignedVersion = [version]'6.2'
|
||||
if ($dsInternalsVersion -eq $unsignedVersion) {
|
||||
Write-Warning ("DSInternals {0} is not digitally signed, which blocks its native DLLs and causes replication failures. Update to v7.0+: Install-Module DSInternals -Force -AllowClobber" -f $dsInternalsVersion)
|
||||
} elseif ($dsInternalsVersion -lt $minimumVersion) {
|
||||
$resp = Read-Host ("DSInternals {0} is installed; v7.0 fixes intermittent replication CRC errors and result truncation. Update now? [Y/N]" -f $dsInternalsVersion)
|
||||
if ($resp -match '^(?i:y|yes)$') {
|
||||
try {
|
||||
# Install-Module -Force is used instead of Update-Module to avoid a PowerShellGet bug
|
||||
# where null PublishedDate metadata causes "cannot convert null to type system.datetime"
|
||||
Install-Module -Name DSInternals -Force -AllowClobber -ErrorAction Stop
|
||||
Write-Host '[+] DSInternals updated. Please re-run the script to load the new version.'
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Warning ("DSInternals update failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve KHDB path with fallbacks
|
||||
$installationPath = $ElysiumSettings["InstallationPath"]
|
||||
if ([string]::IsNullOrWhiteSpace($installationPath)) { $installationPath = $scriptRoot }
|
||||
@@ -561,104 +570,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-Verbose ("Replication permission pre-check passed for '{0}'." -f $Credential.UserName)
|
||||
}
|
||||
|
||||
# Function to test for weak AD passwords
|
||||
function Test-WeakADPasswords {
|
||||
param (
|
||||
@@ -693,9 +604,10 @@ function Test-WeakADPasswords {
|
||||
Write-Verbose ("Using credential supplied by caller: {0}" -f $credential.UserName)
|
||||
}
|
||||
|
||||
# Verify the account has the three replication extended rights before attempting DCSync
|
||||
# Pre-flight checks before attempting DCSync
|
||||
try {
|
||||
$domainInfo = Get-ADDomain -Server $selectedDomain["DC"] -Credential $credential -ErrorAction Stop
|
||||
Test-DCClockSkew -Server $selectedDomain["DC"] -Credential $credential
|
||||
Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName `
|
||||
-Server $selectedDomain["DC"] -Credential $credential
|
||||
} catch {
|
||||
@@ -719,16 +631,64 @@ function Test-WeakADPasswords {
|
||||
$testResults = $accounts | Test-PasswordQuality -WeakPasswordHashesSortedFile $resolvedHashFile.Path
|
||||
Write-Verbose "Password quality test completed."
|
||||
} catch {
|
||||
$message = $_.Exception.Message
|
||||
if ($message -match 'Access is denied') {
|
||||
Write-Error ("Access denied while reading replication data from '{0}' using '{1}'. Ensure this account has Replicating Directory Changes, Replicating Directory Changes All, and Replicating Directory Changes In Filtered Set on the domain." -f $selectedDomain["DC"], $credential.UserName)
|
||||
return
|
||||
$ex = $_.Exception
|
||||
$diagLines = [System.Collections.Generic.List[string]]::new()
|
||||
$diagLines.Add('========================================')
|
||||
$diagLines.Add('ELYSLUM DCSYNC DIAGNOSTIC DUMP')
|
||||
$diagLines.Add('========================================')
|
||||
$diagLines.Add("Timestamp : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
|
||||
$diagLines.Add("Script Ver : $ElysiumVersion")
|
||||
$diagLines.Add("PS Version : $($PSVersionTable.PSVersion)")
|
||||
$diagLines.Add("PS Edition : $($PSVersionTable.PSEdition)")
|
||||
$diagLines.Add("DSInternals : $((Get-Module -Name DSInternals).Version)")
|
||||
$diagLines.Add("DC : $($selectedDomain['DC'])")
|
||||
$diagLines.Add("Domain : $($selectedDomain.Name)")
|
||||
$diagLines.Add("Account : $($credential.UserName)")
|
||||
$diagLines.Add("DomainDN : $($domainInfo.DistinguishedName)")
|
||||
$diagLines.Add("SchemaDN : CN=Schema,CN=Configuration,$($domainInfo.DistinguishedName)")
|
||||
$diagLines.Add('')
|
||||
$diagLines.Add('--- EXCEPTION CHAIN ---')
|
||||
$depth = 0
|
||||
$currentEx = $ex
|
||||
while ($null -ne $currentEx) {
|
||||
$diagLines.Add("Exception $depth : $($currentEx.GetType().FullName)")
|
||||
$diagLines.Add(" Message : $($currentEx.Message)")
|
||||
$diagLines.Add(" HResult : 0x$($currentEx.HResult.ToString('X8'))")
|
||||
$diagLines.Add(" Source : $($currentEx.Source)")
|
||||
if ($currentEx.TargetSite) {
|
||||
$diagLines.Add(" TargetSite : $($currentEx.TargetSite)")
|
||||
}
|
||||
if ($currentEx.StackTrace) {
|
||||
$diagLines.Add(" StackTrace :`n$($currentEx.StackTrace -replace '^', ' ')")
|
||||
}
|
||||
$diagLines.Add('')
|
||||
$currentEx = $currentEx.InnerException
|
||||
$depth++
|
||||
}
|
||||
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
||||
$diagLines.Add('--- END DIAGNOSTIC DUMP ---')
|
||||
|
||||
$diagText = $diagLines -join "`r`n"
|
||||
Write-Host $diagText -ForegroundColor Red
|
||||
|
||||
$diagPath = Join-Path -Path $reportPathBase -ChildPath "dcsync-diag-$timestamp.txt"
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $reportPathBase -Force | Out-Null
|
||||
[System.IO.File]::WriteAllText($diagPath, $diagText, [System.Text.Encoding]::UTF8)
|
||||
Write-Host ("Diagnostic dump written to: {0}" -f $diagPath)
|
||||
} catch {
|
||||
Write-Warning ("Could not write diagnostic dump to disk: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
# Still emit the concise error for the operator
|
||||
$message = $ex.Message
|
||||
if ($message -match 'Replication access was denied|Access is denied') {
|
||||
Write-Error ("Replication access denied from '{0}' using '{1}'.`n`nDSInternals 7.0 fetches the AD schema via DRS before replicating accounts. The schema NC has its own ACL.`nGrant the 3 DCSync extended rights on BOTH:`n 1. {2} (domain NC - for accounts)`n 2. CN=Configuration,{2} (config NC - covers schema NC via inheritance)`n`nIn ADUC: right-click each object > Properties > Security > Advanced > Add the extended rights for '{1}'." -f `
|
||||
$selectedDomain["DC"], $credential.UserName, $domainInfo.DistinguishedName)
|
||||
} elseif ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
||||
Write-Error ("Credentials for '{0}' were rejected by '{1}'. Re-run and provide valid domain credentials." -f $credential.UserName, $selectedDomain["DC"])
|
||||
return
|
||||
} else {
|
||||
Write-Error ("An error occurred while testing passwords: {0}" -f $message)
|
||||
}
|
||||
Write-Error ("An error occurred while testing passwords: {0}" -f $message)
|
||||
return
|
||||
} finally {
|
||||
if ($resolvedHashFile -and $resolvedHashFile.IsTemporary -and (Test-Path -LiteralPath $resolvedHashFile.Path)) {
|
||||
@@ -765,7 +725,10 @@ function Test-WeakADPasswords {
|
||||
}
|
||||
|
||||
Write-Verbose "Generating report at $reportPath"
|
||||
$reportContent = @($header, ($testResults | Out-String).Trim(), $footer) -join "`r`n"
|
||||
# -Width prevents PowerShell's default table formatter from truncating long columns (e.g. a
|
||||
# long SamAccountName) at console/default width, which would otherwise silently break the
|
||||
# first-token re-parse below used to attach "UPN:" lines to dictionary hits.
|
||||
$reportContent = @($header, ($testResults | Out-String -Width 4096).Trim(), $footer) -join "`r`n"
|
||||
|
||||
$lines = $reportContent -split "`r`n"
|
||||
$newReportContent = @()
|
||||
|
||||
+7
-13
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Uninstall.ps1 ##
|
||||
## Version: 2.2.0 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -41,26 +41,20 @@ function Start-UninstallTranscript {
|
||||
function Stop-UninstallTranscript { try { Stop-Transcript | Out-Null } catch {} }
|
||||
|
||||
function Uninstall-Elysium {
|
||||
$ElysiumPath = Get-Location
|
||||
$ElysiumPath = $PSScriptRoot
|
||||
|
||||
Write-Host "Uninstalling Elysium tool from $ElysiumPath..."
|
||||
|
||||
# Check if the Elysium directory exists
|
||||
if (Test-Path $ElysiumPath) {
|
||||
# Schedule the script file for deletion
|
||||
$scriptPath = $MyInvocation.MyCommand.Path
|
||||
$deleteScript = { param($path) Remove-Item -Path $path -Force }
|
||||
Start-Sleep -Seconds 3 # Delay to ensure the script finishes
|
||||
Start-Process -FilePath "powershell.exe" -ArgumentList "-Command", $deleteScript, "-ArgumentList", $scriptPath -WindowStyle Hidden
|
||||
|
||||
# Remove the Elysium directory and all its contents
|
||||
Remove-Item -Path $ElysiumPath -Recurse -Force -Exclude $scriptPath
|
||||
Write-Host "Elysium tool and all related files have been removed, excluding this script. This script will be deleted shortly."
|
||||
# PowerShell reads the whole script into memory before execution begins, so it holds no
|
||||
# open file handle on this script - deleting the install directory (including this file)
|
||||
# while still running is safe and needs no deferred external delete process.
|
||||
Remove-Item -Path $ElysiumPath -Recurse -Force
|
||||
Write-Host "Elysium tool and all related files have been removed."
|
||||
} else {
|
||||
Write-Host "Elysium directory not found. It might have been removed already, or the path is incorrect."
|
||||
}
|
||||
|
||||
# Additional cleanup actions can be added here if needed
|
||||
}
|
||||
|
||||
Start-UninstallTranscript
|
||||
|
||||
+115
-263
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Update-KHDB.ps1 ##
|
||||
## Version: 2.2.0 ##
|
||||
## Version: 2.4.6 ##
|
||||
## 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/$ElysiumVersion (+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,
|
||||
@@ -299,6 +76,7 @@ function Invoke-DownloadWithRetry {
|
||||
$retries = 5
|
||||
$delay = 2
|
||||
for ($attempt = 0; $attempt -lt $retries; $attempt++) {
|
||||
$response = $null
|
||||
try {
|
||||
$response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result
|
||||
if (-not $response.IsSuccessStatusCode) {
|
||||
@@ -338,10 +116,85 @@ function Invoke-DownloadWithRetry {
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
} finally {
|
||||
if ($response) { $response.Dispose() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +240,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
|
||||
@@ -427,8 +267,11 @@ function Validate-Manifest {
|
||||
if (-not $seen.Add($name)) { throw "Manifest contains duplicate shard name '$name'." }
|
||||
}
|
||||
|
||||
if ($Manifest.shardSize -and [int]$Manifest.shardSize -ne 2) {
|
||||
throw "Manifest shardSize $($Manifest.shardSize) is not supported. Expected shardSize 2."
|
||||
# Merge-ShardsToFile reads shard files purely by name/content and doesn't use shardSize for
|
||||
# anything structural, so any prefix length Prepare-KHDBStorage.ps1 can produce (1-8, see its
|
||||
# -ShardSize ValidateRange) is fine here - only reject something outside that supported range.
|
||||
if ($Manifest.shardSize -and ([int]$Manifest.shardSize -lt 1 -or [int]$Manifest.shardSize -gt 8)) {
|
||||
throw "Manifest shardSize $($Manifest.shardSize) is out of the supported range (1-8)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +385,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 +401,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 +570,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 +606,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 +689,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 +705,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 {
|
||||
@@ -933,6 +776,15 @@ function Update-KHDB {
|
||||
$backupPath = Join-Path -Path $installPath -ChildPath ("$khdbName.bak-$ts")
|
||||
Copy-Item -LiteralPath $combinedTarget -Destination $backupPath -Force
|
||||
Write-Host ("Existing KHDB backed up to {0}" -f $backupPath)
|
||||
|
||||
# Each run adds one full-size backup; keep only the most recent few so a recurring
|
||||
# scheduled job doesn't silently accumulate backups until the disk fills up.
|
||||
$backupRetentionCount = 5
|
||||
$staleBackups = Get-ChildItem -LiteralPath $installPath -Filter "$khdbName.bak-*" -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object Name -Descending | Select-Object -Skip $backupRetentionCount
|
||||
foreach ($stale in $staleBackups) {
|
||||
try { Remove-Item -LiteralPath $stale.FullName -Force } catch { Write-Warning "Could not remove old backup '$($stale.FullName)': $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $combinedTemp -Destination $combinedTarget -Force
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Update-LithnetStore.ps1 ##
|
||||
## Version: 2.2.0 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
|
||||
Reference in New Issue
Block a user