54 Commits

Author SHA1 Message Date
tomas.kracmar 2d90656e5c Release v2.4.6: security/correctness fixes from full codebase review
Consolidates this session's review findings, each already fixed and
committed individually: Uninstall.ps1 deleting the wrong directory,
plaintext passphrase handling, a DCSync ACL pre-check bypass via UPN
credentials, an orchestrator that crashed instead of returning to its
menu, plaintext-hash exposure window and unauthenticated AES export
in Extract-NTHashes.ps1, KHDB shard-size/backup/dedup issues in
Prepare-KHDBStorage.ps1 and Update-KHDB.ps1, and assorted resource
leaks. See CHANGELOG.md for the full list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:10:26 +02:00
tomas.kracmar 5b761d8d56 fix(Extract-NTHashes): add HMAC-SHA256 to the AES export (breaking format bump to ELY2)
Protect-FileWithAES used AES-256-CBC with no integrity check. A wrong
passphrase or corrupted/tampered ciphertext just decrypts to garbage
(or an unhelpful padding exception) instead of being detected.

Derives a second 32-byte key from the same PBKDF2 stream (the AES key
and HMAC key are sequential, non-overlapping ranges of one
Rfc2898DeriveBytes instance) and computes HMAC-SHA256 over
magic+salt+iv+ciphertext (encrypt-then-MAC), appended as a trailer.
Bumped the format magic from 'ELY1' to 'ELY2' so old and new files
are distinguishable.

This is a breaking change for whatever external tooling decrypts
these exports (this repo only ever encrypts - decryption happens on
a separate air-gapped machine per the README's FAQ) - documented the
new layout and the break in the README.

Verified with an isolated round-trip test (function extracted,
dot-sourced, paired with a hand-written decrypt+HMAC-verify): correct
passphrase round-trips cleanly, a wrong passphrase is rejected via
HMAC mismatch, and a single flipped ciphertext byte is also rejected
via HMAC mismatch, in all cases before any AES decryption is
attempted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:10:04 +02:00
tomas.kracmar 80199ad7d6 fix(Extract-NTHashes): pin plaintext hash export to UTF-8
Out-File defaults to UTF-16LE on Windows PowerShell 5.1 (Desktop) and
UTF-8 on PowerShell 7 (Core), so the encoding of the transient
plaintext hash export depended purely on which host happened to run
the script - inconsistent with Test-WeakADPasswords.ps1/
Update-KHDB.ps1, which already pin this. Applied the same
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8' pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:06:12 +02:00
tomas.kracmar eea0ddf932 fix(Update-KHDB): dispose HttpResponseMessage on download
Invoke-DownloadWithRetry disposed the response stream and file handle
but never the HttpResponseMessage itself returned by GetAsync,
leaking one per shard download (the S3 code path already disposes
its response object). Added a finally block per attempt to dispose
it on both the success and retry/failure paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:05:30 +02:00
tomas.kracmar 2f0ff9085a fix(Prepare-KHDBStorage): reject manifest shard names outside shard root
In -UploadOnly mode, entry.name from the parsed manifest.json was
used directly in Join-Path (local read) and as the remote object key
with no path-traversal check. A tampered local manifest.json
(requires prior local write access to the shard directory) with a
name like '..\..\secrets.txt' could make the upload step read and
upload an arbitrary local file under an attacker-chosen remote key.
Now resolves each shard's full path via GetFullPath and rejects any
entry that resolves outside the shard root, before it's added to the
list actually used for reads/uploads. Verified the resolved-path
containment check against normal relative names (allowed) and '..'
traversal attempts (blocked).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:04:43 +02:00
tomas.kracmar 03ceec9d5e fix(Elysium.Common): dispose DirectoryEntry/ADSI objects
Test-ReplicationPermissions and Test-DCClockSkew each build a
DirectoryEntry (holding a live ADSI/COM binding, with a plaintext
credential) and never call .Dispose(), leaking the underlying COM
resource on every invocation - noticeable across a run that checks
multiple domains/DCs in one session. Added finally blocks to dispose
both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:03:31 +02:00
tomas.kracmar 06e7607eff fix(Prepare-KHDBStorage): fast-path dedup, and a scope bug hiding it
Two related bugs in Split-KhdbIntoShards:

1. The fast path for plain 32-hex-char lines wrote every line
   straight to its shard with no deduplication, unlike the slow path
   which merges adjacent duplicate hashes via a per-shard pending-line
   buffer. Routed the fast path through the same pending-line buffer
   so adjacent duplicates are dropped there too.

2. While fixing that, found $total (the valid-entry counter) was a
   plain scalar incremented inside $processHashLine, which is invoked
   via the call operator (&). Scriptblocks invoked with & run in
   their own child scope, so '$total++' inside them silently
   incremented a local shadow copy and never updated the real
   counter in the enclosing function - meaning TotalEntries/
   validEntries (used in the returned result, the checkpoint state,
   and the live progress status) were wrong on every run, previously
   masked because actual shard file writes go through $state.Writer
   objects (reference types, unaffected by the scoping issue).
   Moved the counter onto $meta (already a hashtable used the same
   way for TotalLines/InvalidLines/etc.) so it mutates by reference
   across the scope boundary.

Verified both with an isolated test harness (function extracted and
dot-sourced, no AD/Windows dependency): adjacent AAAA/CCCC duplicates
in a 6-line sample now collapse to 3 shard entries, and TotalEntries
correctly reports 3 instead of the previous 0/wrong value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:01:28 +02:00
tomas.kracmar 9bef6d50f5 fix(Test-WeakADPasswords): prevent report column truncation
$testResults | Out-String used the default formatter width, which
truncates long columns at the console/default width. The report text
is re-parsed line-by-line to attach "UPN:" annotations, taking the
first whitespace token as SamAccountName - a truncated account name
would silently fail to match $dictionarySamToUpn and drop the
annotation with no error. Pass -Width 4096 to avoid truncation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:54:47 +02:00
tomas.kracmar d155276366 fix(Extract-NTHashes): remove mismatched blob after checksum failure
On upload checksum mismatch, the script warned and preserved the
local encrypted file but left the already-uploaded, corrupt blob live
in remote storage under its normal name - discoverable only via an
easy-to-miss console warning, and fetchable as if it were good data
by any downstream consumer. Added Invoke-S3DeleteFile (SigV4-signed,
mirrors the existing Put/Get helpers) and now delete the mismatched
blob from S3 (AWS Tools or native HTTP path) or Azure Blob Storage
right after detecting the mismatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:54:21 +02:00
tomas.kracmar 867fb6427d fix(Update-KHDB): cap KHDB backup retention
Every successful update created a new khdb.txt.bak-<timestamp> with
no cleanup anywhere, so a recurring scheduled job accumulated one
full-size backup per run indefinitely. Now keeps only the 5 most
recent backups, pruning older ones after each new backup is created.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:53:26 +02:00
tomas.kracmar 1440b65b9a fix(Update-KHDB): accept manifest's full supported shardSize range
Validate-Manifest hardcoded shardSize -ne 2 as an error, but
Prepare-KHDBStorage.ps1's -ShardSize parameter validly accepts 1-8.
Merge-ShardsToFile doesn't use shardSize for anything structural - it
reads shard files purely by name/content - so the check added no
safety, only rejected manifests produced with any supported shard
size other than 2. Widened the check to the same 1-8 range instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:52:58 +02:00
tomas.kracmar 5691463bd3 fix(Elysium): catch sub-script errors instead of crashing the menu
The menu loop had try/finally with no catch. Update-KHDB.ps1,
Test-WeakADPasswords.ps1, and Extract-NTHashes.ps1 all run under
$ErrorActionPreference = 'Stop' and re-throw on failure, so any
runtime error (bad credentials, unreachable DC, network failure)
propagated uncaught through the parent and killed the whole
orchestrator instead of returning to the menu. Wrapped the switch in
try/catch so a failed option reports the error and redisplays the
menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:52:13 +02:00
tomas.kracmar 91d6bcb216 fix(Test-ReplicationPermissions): resolve UPN-formatted credentials
Get-ADUser -Identity accepts a DN, GUID, SID, or sAMAccountName - not
a UPN. The SID/tokenGroups resolution stripped only a 'DOMAIN\'
prefix, so a UPN-formatted credential (user@domain.tld) had no
backslash to strip, -Identity threw on the full UPN, and the
exception was swallowed by the surrounding catch (Write-Warning +
return), silently skipping the entire DCSync ACL pre-check.

Now branches on the credential format: DOMAIN\user strips the prefix
as before, user@domain.tld resolves via
-Filter "UserPrincipalName -eq '...'", and a bare sAMAccountName is
used as-is. The follow-up tokenGroups lookup now uses the resolved
DistinguishedName instead of re-deriving the username format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:51:41 +02:00
tomas.kracmar ec00518952 fix(passphrase): store DPAPI-protected, never plaintext or echoed
Elysium.ps1 used Read-Host without -AsSecureString (echoed the AES
passphrase to the console) and persisted it as plaintext in
HKCU\Environment via SetEnvironmentVariable, readable by anyone with
console visibility or local registry access. Switched to
Read-Host -AsSecureString and store ConvertFrom-SecureString's DPAPI
output (decryptable only by the same user on the same machine)
instead of the raw value.

Extract-NTHashes.ps1, the only other consumer, now decrypts that
DPAPI-protected string back to a plain string via
ConvertTo-SecureString + NetworkCredential right before handing it to
Protect-FileWithAES. A stale plaintext value from a prior version is
detected and the operator is prompted to re-enter it.

DPAPI protection is Windows-only, consistent with the rest of this
Windows/AD-only tool; not runnable end-to-end on this (non-Windows)
dev machine, verified by AST parse only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:50:59 +02:00
tomas.kracmar 4740cd3e97 fix(Extract-NTHashes): restrict ACL on temp dir holding plaintext hashes
Live NTLM hashes are written unencrypted to a temp file before AES
protection is applied. Cleanup only runs in a finally block, so a
hard kill/crash between write and cleanup could leave plaintext
hashes on disk under a directory that inherits whatever broad ACL its
parent Temp folder has (worst case: C:\Windows\Temp when run as
SYSTEM). Strip inherited ACEs and grant only the current user on the
temp directory right after creating it, narrowing exposure for that
window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:50:16 +02:00
tomas.kracmar 855be8de9c fix(Uninstall): use script location, fix broken self-delete
Two bugs in Uninstall-Elysium:
- $ElysiumPath came from Get-Location (the caller's current working
  directory) instead of $PSScriptRoot. Running the script from any
  CWD other than the install folder recursively force-deleted the
  wrong directory.
- The self-delete workaround was also broken: Remove-Item -Exclude
  matches leaf names via wildcard, not the full path it was given, so
  the exclusion never matched; and the deferred
  "powershell.exe -Command <scriptblock> -ArgumentList <path>" command
  line is not valid syntax for binding $path in the child process.

PowerShell reads a script fully into memory before running it, so it
holds no open handle on the file - deleting the install directory
(including this script) while it's still executing is safe. Dropped
the exclude/deferred-process workaround entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:49:47 +02:00
tomas.kracmar ac3f30db1e fix(Bump-Version): param() must be script's first statement
$ErrorActionPreference and Set-StrictMode sat before param(), so
PowerShell parsed 'param(...)' as a call to a command named param
instead of the script's parameter block ("The function or command
was called as if it were a method"). The script never ran. Moved
both statements after the param block; only #Requires and the
comment-based help may precede param().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:49:21 +02:00
tomas.kracmar 65e451413e Release v2.4.5: detect explicit Deny ACEs in replication permission check
Test-ReplicationPermissions previously only scanned Allow ACEs, so an
explicit Deny on the DCSync extended rights (common in hardening
baselines that Deny a broad group and Allow only named service
accounts) was invisible to the pre-flight 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:49:14 +02:00
tomas.kracmar 1d98b908c6 Release v2.4.4: check schema NC replication rights for DSInternals 7.0
DSInternals 7.0 fetches the AD schema via DRS (GetNCChanges) before
replicating accounts, so the schema NC has its own ACL requirement.

- Test-ReplicationPermissions now validates rights on both the
  domain NC and the configuration NC (schema NC inherits from it).
- Updated README with dsacls delegation examples and dual-NC
  least-privilege requirements.
- Improved 'Replication access was denied' error message to name
  both NCs and explain the DSInternals 7.0 change.
- Diagnostic dump now includes SchemaDN.

All versions bumped to unified v2.4.4.
2026-06-15 08:38:04 +02:00
tomas.kracmar 906bb52638 fix(Test-WeakADPasswords): add comprehensive DCSync diagnostic dump
When Get-ADReplAccount or Test-PasswordQuality throws, the catch
block now dumps the full exception chain (type, message, HResult,
source, target site, stack trace, inner exceptions) along with
runtime context (Elysium version, PS version, DSInternals version,
DC, domain, account). Output goes to console and a timestamped
 diagnostic file under Reports/ for offline analysis.
2026-06-09 16:23:38 +02:00
tomas.kracmar af945f529e Release v2.4.3: fix tokenGroups retrieval and DirectoryEntry LDAP paths
Test-ReplicationPermissions:
- Replaced DirectoryEntry.RefreshCache tokenGroups retrieval with
  Get-ADUser -Properties tokenGroups. DirectoryEntry does not
  understand URI percent-encoding, so the v2.4.1 EscapeDataString
  fix caused 'invalid dn syntax' errors.
- Removed EscapeDataString from the ACL DirectoryEntry path as
  well; DirectoryEntry expects raw LDAP ADSI path syntax.

All versions bumped to unified v2.4.3.
2026-06-09 14:14:45 +02:00
tomas.kracmar 03aa72f999 Release v2.4.2: replace em-dashes with ASCII hyphens to fix encoding parse errors
UTF-8 em-dashes (U+2014) in Elysium.Common.ps1 string literals were
being misinterpreted by Windows PowerShell as containing quote
characters when the file was read without a UTF-8 BOM. This caused
cascading parse errors: unexpected tokens, missing closing braces,
and missing catch blocks.

All em-dashes in .ps1 files have been replaced with ASCII hyphens.
All versions bumped to unified v2.4.2.
2026-06-09 13:51:13 +02:00
tomas.kracmar 10cbf0285d Release v2.4.1: URI-escape DNs in DirectoryEntry LDAP URLs
Test-ReplicationPermissions and Test-DCClockSkew now escape
Distinguished Names via [System.Uri]::EscapeDataString before
constructing DirectoryEntry LDAP URLs. This prevents URL
mis-parsing when DNs contain /, #, or other reserved characters.

All versions bumped to unified v2.4.1.
2026-06-09 13:42:34 +02:00
tomas.kracmar fc91f0d6b0 Release v2.4.0: DC clock skew check, SDProp/Protected Users warnings, and DSInternals install fix
Added pre-flight diagnostics:
- Test-DCClockSkew: validates local/DC clock skew before DCSync to
  catch Kerberos auth failures early.
- Test-ReplicationPermissions now warns on adminCount=1 (SDProp
  protected) and Protected Users group membership (RID 525), both
  of which can silently block or revert replication rights.

Fixed DSInternals update flow:
- Replaced Update-Module with Install-Module -Force -AllowClobber
  to work around a PowerShellGet null PublishedDate bug.

All versions bumped to unified v2.4.0.
2026-06-09 13:32:21 +02:00
tomas.kracmar 6b2ae6c8b5 Release v2.3.0: add DSInternals version check and auto-update
Test-WeakADPasswords.ps1 now validates the installed DSInternals
version at startup:
- v6.2 (unsigned) warns that native DLLs are blocked and replication
  will fail; directs operator to Update-Module DSInternals.
- Below v7.0 prompts to auto-update via Update-Module -Force and
  exits cleanly so the new version is loaded on re-run.
- v7.0+ passes silently.

All versions bumped to unified v2.3.0.
2026-06-09 13:16:47 +02:00
tomas.kracmar 37d1a8d971 Release v2.2.5: resolve DSInternals module path in block error
The Zone.Identifier block detection now dynamically resolves the
actual DSInternals module installation path via Get-Module instead
of hardcoding a ProgramFiles path, so the Unblock-File command in
the error message is always correct.

All versions bumped to unified v2.2.5.
2026-06-09 13:10:36 +02:00
tomas.kracmar 0175864e72 Release v2.2.4: permission check InheritOnly fix and DSInternals block detection
Test-ReplicationPermissions:
- Skip InheritOnly ACEs since they do not apply to the domain root
  object itself, only to child objects.

Test-WeakADPasswords:
- Detect Windows Zone.Identifier blocks on DSInternals DLLs and
  emit a clear error with the exact Unblock-File remediation
  command instead of a vague warning.

All versions bumped to unified v2.2.4.
2026-06-09 13:07:46 +02:00
tomas.kracmar 9496063b97 Release v2.2.3: improve replication permission detection
Test-ReplicationPermissions now recognizes:
- GenericAll as satisfying replication rights
- Blanket ExtendedRight (empty ObjectType) ACEs

Also adds diagnostic hints distinguishing between
'missing ACE entirely' and 'ACE exists but not for you'.

All versions bumped to unified v2.2.3.
2026-06-09 11:53:44 +02:00
tomas.kracmar 27a682a968 Release v2.2.2: fix replication permission check for nested groups
Test-ReplicationPermissions now uses the tokenGroups constructed
attribute to resolve all effective SIDs in the caller's Kerberos
token, including nested group memberships. This replaces the
previous MemberOf walk which missed indirect entitlement and
could produce false-positive missing-permission errors.

All versions bumped to unified v2.2.2.
2026-06-09 11:41:14 +02:00
tomas.kracmar 255cfe0a17 chore: centralize version and add Bump-Version.ps1
- Add  to Elysium.Common.ps1 as the single
  runtime source of truth for version strings.
- Update Update-KHDB.ps1 User-Agent to reference .
- Update Test-WeakADPasswords.ps1 usage beacon payload to reference
  .
- Add Bump-Version.ps1 release helper that updates the centralized
  variable, ASCII headers across .ps1/.py files, runtime references,
  and prints a CHANGELOG stub.
2026-06-09 11:14:20 +02:00
tomas.kracmar 09c30f97e9 Release v2.2.1: DRY refactoring and housekeeping
Consolidated duplicated helpers into Elysium.Common.ps1:
- Settings parsing (Read-KeyValueSettingsFile, Read-ElysiumSettings, Get-SettingsValue)
- Azure Blob URI builder (Build-BlobUri)
- S3 SigV4 signing helpers and AWS module bootstrap
- AD credential validation and replication permission pre-check
- Parallel execution helper (Get-FunctionDefinitionText)

Test-WeakADPasswords.ps1 and Extract-NTHashes.ps1 now import
Elysium.Common.ps1 for the first time. Update-KHDB.ps1 and
Prepare-KHDBStorage.ps1 removed their local duplicates.

Deleted legacy Settings.ps1 (superseded by ElysiumSettings.txt).
Removed stray placeholder comment in Elysium.ps1.

All versions bumped to unified v2.2.1.
2026-06-09 10:52:19 +02:00
tomas.kracmar 5127c2d096 fix(Test-WeakADPasswords): surface replication permission success to console
Replace Write-Verbose with Write-Host so operators see the
permissions verification result without needing -Verbose.
2026-06-09 09:56:24 +02:00
tomas.kracmar ad1db86232 Release v2.2.0: unified versioning and replication permission pre-check
- Unified project versioning (v2.2.0) across all scripts, settings template,
  and documentation. All components now share a single version number.
- Added Test-ReplicationPermissions to Test-WeakADPasswords.ps1 to validate
  the three required AD replication extended rights before DCSync, providing
  clear fail-fast errors when permissions are missing.
- Updated CHANGELOG.md with unified versioning strategy and release history.
- Updated README.md with versioning section and improved troubleshooting docs.
2026-06-09 09:43:43 +02:00
tomas.kracmar 60a7671ceb Fix KHDB password match format handling 2026-03-16 16:38:19 +01:00
Tom Frost 787360c706 Fail fast when FIPS policy is enabled for weak password test 2026-02-17 13:28:39 +01:00
Tom Frost baaee8dc53 Handle DSInternals FIPS bootstrap WriteErrorException 2026-02-17 13:15:14 +01:00
Tom Frost b582bb24b3 Bump Test-WeakADPasswords to v1.4.1 2026-02-17 13:03:34 +01:00
Tom Frost 7f1df7b102 Improve weak password test credential diagnostics and docs 2026-02-17 12:40:04 +01:00
tomas.kracmar 7874c0e65b Clarified privileges needed 2025-12-08 16:24:26 +01:00
tomas.kracmar be96cbf9a5 Bumped versions 2025-11-07 20:56:02 +01:00
tomas.kracmar 42fee2ff84 PS fix 2025-11-07 20:48:52 +01:00
tomas.kracmar 2ff4964537 PS7 fixes 2025-11-07 20:45:53 +01:00
tomas.kracmar 6e4cc874b0 PS 7 bug fixes 2025-11-07 20:42:58 +01:00
tomas.kracmar ec27206453 PS7 fixes 2025-11-07 20:38:26 +01:00
tomas.kracmar a55ef3713f Bug fixes 2025-11-07 20:33:21 +01:00
tomas.kracmar bda19432e2 Update to prefer PS7 if available 2025-11-07 20:21:03 +01:00
tomas.kracmar 7c2bb65a86 Parallel transfers 2025-11-07 18:14:43 +01:00
tomas.kracmar 5a64558bb9 Fix PS 5.1 -Depth handling 2025-11-07 16:27:00 +01:00
tomas.kracmar 4b1b841383 KHDB rework 2025-11-07 15:58:35 +01:00
tomas.kracmar 964e91d20f Fixing handling errors in datasets 2025-10-21 15:34:44 +02:00
tomas.kracmar 353352eeb2 Improve UPN export handling 2025-10-21 14:27:16 +02:00
tomas.kracmar 05e9358357 Versions bump 2025-10-21 13:42:46 +02:00
tomas.kracmar 5799881418 Fixing table sorting and S3 upload 2025-10-21 13:35:09 +02:00
tomas.kracmar 0d9a460057 Fix S3 download 2025-10-20 19:19:14 +02:00
14 changed files with 3947 additions and 595 deletions
+5 -1
View File
@@ -2,4 +2,8 @@
khdb.txt
khdb.txt.zip
ElysiumSettings.txt
/Reports
/Reports
/khdb-shards
khdb-manifest.json
/elysium/.vscode
/.vscode
+125
View File
@@ -0,0 +1,125 @@
# Elysium — Agent Context
> This file is written for AI coding agents. The project language is English; all code comments, documentation, and settings are in English.
## Project Overview
Elysium is a Windows-focused Active Directory weak-password assessment toolkit. It is implemented entirely in PowerShell (with one Python decryption helper) and is designed to be run from a dedicated, trusted Windows host by an operator with replication-equivalent rights on the target AD domain.
The tool performs three core workflows:
1. **Update Known-Hashes Database (KHDB)** — Download an incremental, sharded hash database from remote storage (Azure Blob or S3-compatible).
2. **Test Weak AD Passwords** — Use DSInternals to replicate account data and test passwords against the local KHDB, producing timestamped text reports.
3. **Extract and Send Current Hashes** — Pull NTLM hashes (without usernames) from live AD, compress, encrypt, and upload them back to the tool provider to improve the KHDB. This step is optional.
4. **Update Lithnet Password Protection Store** — Populate a local Lithnet Password Protection store with compromised hashes, plaintext passwords, and banned words.
## Technology Stack
- **Runtime:** Windows PowerShell 5.1 or PowerShell 7 (`pwsh`).
- **Required PowerShell modules:** `DSInternals`, `ActiveDirectory` (RSAT).
- **Optional PowerShell modules:** `Az.Storage`, `AWS.Tools.S3` / `AWSPowerShell.NetCore`, `LithnetPasswordProtection`.
- **Python 3:** `decrypt.py` requires `pycryptodome` for decrypting uploaded payload files.
- **Network:** TLS 1.2+ enforced; native S3 SigV4 signing implemented in pure PowerShell/.NET (no AWS Tools required).
## Project Structure
```
Elysium.ps1 # Main menu / orchestrator script
Elysium.Common.ps1 # Shared helpers: pwsh relauncher, Windows PS fallback
ElysiumSettings.txt # Live configuration (key=value format, # comments)
ElysiumSettings.txt.sample # Documented template for operators
Update-KHDB.ps1 # KHDB downloader: manifest + shard incremental update
Prepare-KHDBStorage.ps1 # KHDB publisher: split source into shards, build manifest, upload
Test-WeakADPasswords.ps1 # AD password-quality test with DSInternals
Extract-NTHashes.ps1 # Hash extraction, compression, encryption, upload
Update-LithnetStore.ps1 # Lithnet Password Protection store importer
Uninstall.ps1 # Self-removal script
decrypt.py # Python 3 decryptor for .enc payloads
Settings.ps1 # Legacy settings file (superseded by ElysiumSettings.txt)
khdb.txt # Local merged KHDB file (generated by Update-KHDB.ps1)
Reports/ # Generated reports and transcript logs
```
## Configuration Format
`ElysiumSettings.txt` uses a simple `Key=value` format. Lines starting with `#` are comments. Values are **not** quoted by default; if quotes are used they are stripped by some parsers.
Key sections:
- `StorageProvider``Azure` or `S3`
- Azure: `storageAccountName`, `containerName`, `sasToken`
- S3: `s3EndpointUrl`, `s3Region`, `s3BucketName`, `s3AccessKeyId`, `s3SecretAccessKey`, `s3ForcePathStyle`, `s3UseAwsTools`
- KHDB layout: `KhdbManifestPath`, `KhdbShardPrefix`, `KhdbLocalShardDir`
- App: `InstallationPath`, `ReportPathBase`, `WeakPasswordsDatabase`, `CheckOnlyEnabledUsers`
- Lithnet: `LithnetStorePath`, `LithnetSyncHibp`, `LithnetHashSources`, `LithnetPlaintextSources`, `LithnetBannedWordSources`
- Telemetry (optional): `UsageBeaconUrl`, `UsageBeaconMethod`, `UsageBeaconInstanceId`, `UsageBeaconTimeoutSeconds`
- Domains: `Domain1Name`, `Domain1DC`, `Domain2Name`, `Domain2DC`, ...
## PowerShell Edition Handling
This project has nuanced PowerShell edition rules that must be preserved:
- **Scripts that benefit from parallelism** (`Update-KHDB.ps1`, `Prepare-KHDBStorage.ps1`, `Update-LithnetStore.ps1`, `Uninstall.ps1`) auto-relaunch under `pwsh` (PowerShell 7+) via `Restart-WithPwshIfAvailable` in `Elysium.Common.ps1`.
- **Scripts that require legacy modules** (`Test-WeakADPasswords.ps1`, `Extract-NTHashes.ps1`) auto-relaunch under `Windows PowerShell` (`powershell.exe`) via `Restart-WithWindowsPowerShellIfAvailable`, because `DSInternals` and `ActiveDirectory` often need the Desktop edition.
- `Elysium.ps1` (the menu) checks `$PSVersionTable.PSEdition` and invokes the appropriate child process for options 2 and 3.
When modifying any script, keep the `. $commonHelper` import and the corresponding `Restart-With*IfAvailable` call at the top.
## Code Style Guidelines
- Strict mode and error handling are mandatory at the top of every script:
```powershell
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
```
- Every operational script starts a **transcript** into `Reports/logs/` (or `%TEMP%` for uninstall) and stops it in a `finally` block.
- Functions use `Verb-Noun` naming. Private helpers are also `Verb-Noun`.
- Settings parsing is done manually via `Get-Content` and string splitting on `=`, not `ConvertFrom-StringData`.
- Paths are resolved relative to `$PSScriptRoot` whenever possible.
- Large file operations use `StreamReader` / `StreamWriter` with explicit `UTF8Encoding($false)` (no BOM) and large buffers (1 MiB preferred).
- S3-native functions (SigV4) are duplicated in scripts that need them because they run in parallel `ForEach-Object -Parallel` scopes where module imports are not preserved.
## Build / Run Commands
There is no package manager or build system. The tool is run directly:
```powershell
# Main menu
.\Elysium.ps1
# Direct invocation (respects auto-relaunch behavior)
.\Update-KHDB.ps1
.\Update-KHDB.ps1 -MaxParallelTransfers 8
.\Test-WeakADPasswords.ps1
.\Extract-NTHashes.ps1
.\Update-LithnetStore.ps1
.\Prepare-KHDBStorage.ps1 -SourcePath .\khdb.txt -OutputRoot .\publish -StorageProvider S3 -SkipUpload
.\Uninstall.ps1
```
No tests or CI pipelines exist in this repository.
## Testing & Validation
- **KHDB validation:** `Validate-KHDBFile` in `Update-KHDB.ps1` checks 32-hex format, sorting, and duplicates.
- **DSInternals compatibility:** `Resolve-DSInternalsWeakHashFile` in `Test-WeakADPasswords.ps1` normalizes legacy `HASH:count` lines into a temporary hash-only file.
- **Upload integrity:** After every upload, the script downloads the blob back and compares SHA256 checksums before deleting local artifacts.
- **Credential pre-check:** `Test-WeakADPasswords.ps1` validates credentials against the chosen DC with `Get-ADDomain` and checks the three replication extended rights before attempting DCSync.
## Security Considerations
- The operator passphrase is stored in the **user-level** environment variable `ELYSIUM_PASSPHRASE`. Do not log or serialize it.
- `Test-WeakADPasswords.ps1` refuses to run when Windows FIPS policy is enabled (`HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy\Enabled = 1`) because DSInternals operations may fail or be limited.
- The extract-and-send workflow uploads **hashes only** (no usernames). Encryption uses PBKDF2 (SHA-256, 100k iterations, random 16-byte salt) + AES-256-CBC with a 4-byte magic header `ELY1`.
- S3 requests are signed with native SigV4 via `System.Net.Http.HttpClient` so no AWS credentials file or third-party module is required. If AWS Tools are used (`s3UseAwsTools = true`), the native path is still available as a fallback.
- Reports contain sensitive AD data. They are written to `Reports/` under the script root by default.
- All network operations enforce TLS 1.2+ (`[System.Net.ServicePointManager]::SecurityProtocol`).
## Notes for Agents
- Do **not** introduce new package-manager files (e.g., `package.json`, `pyproject.toml`, `Cargo.toml`). This project is intentionally dependency-light and script-driven.
- When adding new settings, keep the `key=value` flat format and update both `ElysiumSettings.txt.sample` and any parser that reads the file.
- If you add a new operational script, include the standard banner, strict mode, transcript logging, and the `Elysium.Common.ps1` relauncher pattern.
- The `.gitignore` excludes `khdb.txt`, `ElysiumSettings.txt`, `Reports/`, `khdb-shards/`, and `khdb-manifest.json`.
+162
View File
@@ -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"
+257 -13
View File
@@ -1,30 +1,274 @@
# Changelog
## 2025-10-10
All notable changes to the Elysium project are documented in this file.
### Test-WeakADPasswords.ps1 v1.3.0
Starting with **v2.2.0**, Elysium uses a **unified project version**. All scripts, settings templates, and documentation share the same version number so operators can verify consistency at a glance. Releases prior to v2.2.0 used per-script versioning; those entries are preserved below under their original dates.
---
## [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
- **Unified versioning:** All PowerShell scripts, the settings template, and documentation now share a single project version (`2.2.0`). This replaces the previous per-script versioning model.
### Test-WeakADPasswords.ps1
- Added `Test-ReplicationPermissions` helper that validates the three required AD replication extended rights (`Replicating Directory Changes`, `Replicating Directory Changes All`, `Replicating Directory Changes In Filtered Set`) against the domain object's DACL before attempting DCSync. Missing permissions now produce a clear, fail-fast error instead of an opaque `Access is denied` later in the workflow.
---
## Historical Releases (per-script versioning)
### 2026-03-16
#### Test-WeakADPasswords.ps1 v1.4.5
Fixed:
- Normalizes legacy `HASH:count` KHDB files into a temporary hash-only list before calling `DSInternals`, so dictionary matches no longer fail silently when clients have older database content.
- Warns when KHDB normalization is required instead of leaving the weak-password match section empty without explanation.
#### Update-KHDB.ps1 v2.1.1
Fixed:
- Rebuilds the merged local `khdb.txt` as a DSInternals-compatible hash-only file even when upstream shards still contain legacy `HASH:count` lines.
- Tightened KHDB merge validation so malformed shard content is surfaced during update rather than producing a silently unusable weak-password database.
#### Prepare-KHDBStorage.ps1 v1.1.1
Fixed:
- Accepts legacy `HASH:count` source input but writes deduplicated hash-only shards for downstream DSInternals consumers.
#### README.md
Changed:
- Corrected the KHDB format documentation to require one NT hash per line and documented the automatic legacy-format normalization.
### 2026-02-17
#### Test-WeakADPasswords.ps1 v1.4.4
Changed:
- Added startup FIPS policy detection (`HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy\Enabled`) with fail-fast behavior and explicit remediation steps to avoid opaque DSInternals runtime failures.
#### Test-WeakADPasswords.ps1 v1.4.3
Fixed:
- Added explicit handling for `Microsoft.PowerShell.Commands.WriteErrorException,DSInternals.Bootstrap.psm1` so known FIPS bootstrap errors are downgraded to a controlled warning when possible, with a clear fail message if DSInternals cannot load under policy.
#### Test-WeakADPasswords.ps1 v1.4.2
Fixed:
- DSInternals module import now handles the known FIPS bootstrap warning as non-fatal when the module successfully loads, preventing repeated `SecurityError` noise during startup.
#### Test-WeakADPasswords.ps1 v1.4.1
Changed:
- Added credential pre-validation against the selected domain controller before running `Get-ADReplAccount`, including retry prompts for rejected credentials.
- Improved error diagnostics to distinguish invalid credentials from missing replication permissions (`Access is denied`).
- Added optional `-Credential` parameter to `Test-WeakADPasswords` for callers that need to provide credentials non-interactively.
#### README.md
Changed:
- Updated weak-password testing documentation to reflect credential pre-check behavior and added a short troubleshooting section for common authentication/permissions failures.
### 2025-10-30
#### Update-KHDB.ps1 v2.0.0
Changed:
- Replaced single-archive workflow with manifest-driven, two-hex shard downloads that verify SHA256/size before in-place updates.
- Added incremental refresh logic, stale shard cleanup, and automatic rebuild of the merged `khdb.txt` for downstream scripts.
- Hardened validation to stream-check merged output while preserving strict TLS, retry, and transcript behaviour.
#### ElysiumSettings.txt.sample v1.3.0
Added:
- Documented `KhdbManifestPath`, `KhdbShardPrefix`, and `KhdbLocalShardDir` defaults for the shard-aware updater.
#### README.md
Changed:
- Described the manifest/shard update flow so operators understand the incremental download model and automatic cleanup.
#### Prepare-KHDBStorage.ps1 v1.0.0
Added:
- Helper script to split `khdb.txt` (or a directory/list of `.gz` HIBP slices) into two-hex shards, build the JSON manifest, and push the package to Azure Blob Storage or S3-compatible endpoints.
- Validation step that tallies and quarantines malformed hashes before sharding, writing `invalid-hashes.txt` plus a console summary so bad data never reaches storage.
- Optional `-ShowProgress` mode emitting periodic `Write-Progress` updates (interval configurable) so large ingests visibly tick forward.
- Automatic reconstruction of HIBP NTLM hashes (file-prefix + suffix) so partially stored hashes still produce full 32-hex values in the shards, plus per-prefix deduplication that keeps the highest observed count.
- `-ForcePlainText` switch to skip `.gz` expansions entirely and treat the source as pre-built hash lines (skipped entries are reported separately).
- Emits a merged `khdb-clean.txt` alongside the shards for DSInternals or offline review, including SHA256 fingerprints for both manifest and clean output.
- Automatic checkpoint/resume when `-ForcePlainText` is used (configurable via `-CheckpointPath`, disable with `-NoCheckpoint`) so large ingests can be paused and resumed without reprocessing prior shards.
### 2025-10-26
#### Test-WeakADPasswords.ps1 v1.3.3
Added:
- Opt-in usage beacon that fires a single HTTP request (GET/POST/PUT) after settings load, suitable for pre-signed S3 URLs, and only includes script name, version, and a UTC timestamp (plus optional instance ID).
- Instance identifier header/body support and configurable timeout so adopters can differentiate deployments without collecting user data.
#### ElysiumSettings.txt.sample v1.2.0
Added:
- Documented `UsageBeacon*` keys (URL, method, instance ID, timeout) so telemetry stays disabled by default but easy to enable.
#### README.md
Added:
- Usage beacon section explaining how to configure the lightweight tracking call and what metadata is transmitted.
### 2025-10-21
#### Extract-NTHashes.ps1 v1.2.1
Fixed:
- Corrected SigV4 host header formatting so non-default ports serialize without parser errors.
- Hardened hashing helpers to avoid `ComputeHash` overload ambiguity under Windows PowerShell.
- Domain selection menu now respects the configured numeric order.
#### Test-WeakADPasswords.ps1 v1.3.2
Changed:
- Switched to the sorted KHDB path when driving `Test-PasswordQuality`, eliminating full linear scans and avoiding malformed-line crashes on massive datasets.
#### Test-WeakADPasswords.ps1 v1.3.1
Fixed:
- Domain picker now renders in numeric order from settings for predictable operator workflows.
- UPN export now relies on structured weak-password results, so dictionary hit UPN lists are populated reliably.
### 2025-10-10
#### Test-WeakADPasswords.ps1 v1.3.0
Added:
- `CheckOnlyEnabledUsers` flag wired from settings to filter accounts prior to `Test-PasswordQuality`.
- Transcript logging to `Reports/logs/test-weakad-<timestamp>.log`.
### Extract-NTHashes.ps1 v1.2.0
#### Extract-NTHashes.ps1 v1.2.0
Added:
- Transcript logging to `Reports/logs/extract-hashes-<timestamp>.log`.
### Elysium.ps1 v1.1.0
#### Elysium.ps1 v1.1.0
Updated:
- Added strict error handling (`$ErrorActionPreference='Stop'`) and `Set-StrictMode`.
- Resolved script invocations via `$PSScriptRoot` to avoid CWD issues.
### Elysium.ps1 v1.2.0
#### Elysium.ps1 v1.2.0
Added:
- Transcript logging to `Reports/logs/orchestrator-<timestamp>.log` and graceful shutdown without `exit`.
### Uninstall.ps1 v1.1.0
#### Uninstall.ps1 v1.1.0
Added:
- Transcript logging to `%TEMP%/Elysium/logs/uninstall-<timestamp>.log` so logs persist after directory removal.
### Update-KHDB.ps1 v1.1.0
#### Update-KHDB.ps1 v1.1.0
Added/Updated:
- Robust settings validation and SAS token normalization.
- Safe URL construction with `UriBuilder` and custom User-Agent.
@@ -34,7 +278,7 @@ Added/Updated:
- KHDB validation: format check (32-hex), deduplication and normalization.
- Transcript logging to `Reports/logs/update-khdb-<timestamp>.log`.
### Test-WeakADPasswords.ps1 v1.2.0
#### Test-WeakADPasswords.ps1 v1.2.0
Updated:
- Enforced modules via `#Requires`; removed runtime installs.
- Added strict mode and error preference.
@@ -42,7 +286,7 @@ Updated:
- Ensured report directory creation and sane defaults (`Reports`).
- Removed stray top-level loop; UPN enrichment occurs during report generation only.
### Extract-NTHashes.ps1 v1.1.0
#### Extract-NTHashes.ps1 v1.1.0
Updated:
- Enforced modules via `#Requires`; added strict mode.
- Fixed variable ordering bug and unified filename scheme with domain prefix.
@@ -50,18 +294,18 @@ Updated:
- Normalized SAS token and verified container existence; checksum verified before cleanup; artifacts retained on failure.
- Paths resolved relative to `$PSScriptRoot`; ensured report base directory exists.
### ElysiumSettings.txt.sample v1.1.0
#### ElysiumSettings.txt.sample v1.1.0
Updated:
- `ReportPathBase` default changed to `Reports` (relative) and added guidance on required modules and replication rights.
- Added optional `CheckOnlyEnabledUsers=true` example flag.
## Extract-NTHashes.ps1
### Extract-NTHashes.ps1
### version 1.1.1
#### version 1.1.1
**Updated:**
- UPNs of the accounts with passwords found in dictionary were moved into separate report (one UPN at a line) to enable further automation.
### version 1.1.0
#### version 1.1.0
**Added:**
- UPN retrieval (this will prolong the time needed to run the script significantly)
- Better error handling
+512
View File
@@ -0,0 +1,512 @@
$script:ElysiumVersion = '2.4.6'
function Invoke-RestartWithExecutable {
param(
[string]$ExecutablePath,
[hashtable]$BoundParameters,
[object[]]$UnboundArguments
)
if (-not $ExecutablePath) { return }
if (-not $PSCommandPath) { return }
$argList = @('-NoLogo', '-NoProfile', '-File', $PSCommandPath)
if ($BoundParameters) {
foreach ($entry in $BoundParameters.GetEnumerator()) {
$key = "-$($entry.Key)"
$value = $entry.Value
if ($value -is [System.Management.Automation.SwitchParameter]) {
if ($value.IsPresent) { $argList += $key }
} else {
$argList += $key
$argList += $value
}
}
}
if ($UnboundArguments) {
$argList += $UnboundArguments
}
& $ExecutablePath @argList
exit $LASTEXITCODE
}
function Restart-WithPwshIfAvailable {
param(
[hashtable]$BoundParameters,
[object[]]$UnboundArguments
)
if ($PSVersionTable.PSVersion.Major -ge 7 -or $PSVersionTable.PSEdition -eq 'Core') { return }
$pwsh = Get-Command -Name 'pwsh' -ErrorAction SilentlyContinue
if (-not $pwsh) { return }
Write-Host ("PowerShell 7 detected at '{0}'; relaunching script under pwsh..." -f $pwsh.Path)
Invoke-RestartWithExecutable -ExecutablePath $pwsh.Path -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
}
function Restart-WithWindowsPowerShellIfAvailable {
param(
[hashtable]$BoundParameters,
[object[]]$UnboundArguments
)
if ($PSVersionTable.PSEdition -eq 'Desktop') { return }
$powershellCmd = Get-Command -Name 'powershell.exe' -ErrorAction SilentlyContinue
$powershellPath = $null
if ($powershellCmd) {
$powershellPath = $powershellCmd.Path
} else {
$defaultPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe'
if (Test-Path -LiteralPath $defaultPath) {
$powershellPath = $defaultPath
}
}
if (-not $powershellPath) {
Write-Warning 'Windows PowerShell (powershell.exe) was not found; continuing under current host.'
return
}
Write-Host ("Windows PowerShell detected at '{0}'; relaunching script under powershell.exe..." -f $powershellPath)
Invoke-RestartWithExecutable -ExecutablePath $powershellPath -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
}
# ---------------------------------------------------------------------------
# Settings loading
# ---------------------------------------------------------------------------
function Read-KeyValueSettingsFile {
param([Parameter(Mandatory)][string]$Path)
$result = @{}
if (-not (Test-Path -LiteralPath $Path)) { return $result }
foreach ($line in (Get-Content -LiteralPath $Path)) {
if ($null -eq $line) { continue }
$trimmed = $line.Trim()
if (-not $trimmed) { continue }
if ($trimmed.StartsWith('#')) { continue }
$kv = $line -split '=', 2
if ($kv.Count -ne 2) { continue }
$key = $kv[0].Trim()
$value = $kv[1].Trim()
if (-not $key) { continue }
if ($value.StartsWith("'") -and $value.EndsWith("'") -and $value.Length -ge 2) {
$value = $value.Substring(1, $value.Length - 2)
}
$result[$key] = $value
}
return $result
}
function Read-ElysiumSettings {
param([Parameter(Mandatory)][string]$ScriptRoot)
$settingsPath = Join-Path -Path $ScriptRoot -ChildPath 'ElysiumSettings.txt'
if (-not (Test-Path -LiteralPath $settingsPath)) {
throw "Settings file not found at $settingsPath"
}
return Read-KeyValueSettingsFile -Path $settingsPath
}
function Get-SettingsValue {
param(
[hashtable]$Settings,
[string]$Key
)
if (-not $Settings) { return $null }
if ($Settings.ContainsKey($Key)) { return $Settings[$Key] }
return $null
}
# ---------------------------------------------------------------------------
# Parallel execution helpers
# ---------------------------------------------------------------------------
function Get-FunctionDefinitionText {
param([Parameter(Mandatory)][string]$Name)
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
return $cmd.ScriptBlock.Ast.Extent.Text
}
# ---------------------------------------------------------------------------
# Azure Blob Storage helpers
# ---------------------------------------------------------------------------
function Build-BlobUri {
param(
[string]$Account,
[string]$Container,
[string]$Sas,
[string]$BlobName
)
if ([string]::IsNullOrWhiteSpace($Account)) { throw 'storageAccountName is missing or empty.' }
if ([string]::IsNullOrWhiteSpace($Container)) { throw 'containerName is missing or empty.' }
if ([string]::IsNullOrWhiteSpace($Sas)) { throw 'sasToken is missing or empty.' }
if ([string]::IsNullOrWhiteSpace($BlobName)) { throw 'BlobName cannot be empty.' }
$sas = $Sas.Trim()
if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
$normalizedBlob = $BlobName.Replace('\', '/').TrimStart('/')
$builder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob")
$builder.Query = $sas.TrimStart('?')
return $builder.Uri.AbsoluteUri
}
# ---------------------------------------------------------------------------
# Storage path utilities
# ---------------------------------------------------------------------------
function Combine-StoragePath {
param(
[string]$Prefix,
[string]$Name
)
$cleanName = $Name.Replace('\', '/').TrimStart('/')
if ([string]::IsNullOrWhiteSpace($Prefix)) { return $cleanName }
$normalizedPrefix = $Prefix.Replace('\', '/').Trim('/')
if ([string]::IsNullOrEmpty($normalizedPrefix)) { return $cleanName }
return "$normalizedPrefix/$cleanName"
}
# ---------------------------------------------------------------------------
# AWS SigV4 / S3 helpers
# ---------------------------------------------------------------------------
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
function Get-HashHex([byte[]]$bytes) {
if ($null -eq $bytes) { $bytes = [byte[]]@() }
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$bytes)
try {
$hash = $sha.ComputeHash([System.IO.Stream]$ms)
} finally { $ms.Dispose() }
return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
} finally { $sha.Dispose() }
}
function HmacSha256([byte[]]$key, [string]$data) {
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
try {
$b = [System.Text.Encoding]::UTF8.GetBytes($data)
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$b)
try {
return $h.ComputeHash([System.IO.Stream]$ms)
} finally { $ms.Dispose() }
} finally { $h.Dispose() }
}
function GetSignatureKey([string]$secret, [string]$dateStamp, [string]$regionName, [string]$serviceName) {
$kDate = HmacSha256 (Get-Bytes ('AWS4' + $secret)) $dateStamp
$kRegion = HmacSha256 $kDate $regionName
$kService = HmacSha256 $kRegion $serviceName
HmacSha256 $kService 'aws4_request'
}
function UriEncode([string]$data, [bool]$encodeSlash) {
if ($null -eq $data) { return '' }
$enc = [System.Uri]::EscapeDataString($data)
if (-not $encodeSlash) { $enc = $enc -replace '%2F', '/' }
return $enc
}
function BuildCanonicalPath([System.Uri]$uri) {
$segments = $uri.AbsolutePath.Split('/')
$encoded = @()
foreach ($s in $segments) { $encoded += (UriEncode $s $false) }
$path = ($encoded -join '/')
if (-not $path.StartsWith('/')) { $path = '/' + $path }
return $path
}
function ToHex([byte[]]$b) { ([BitConverter]::ToString($b)).Replace('-', '').ToLowerInvariant() }
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
$algorithm = 'AWS4-HMAC-SHA256'
$timestamp = (Get-Date).ToUniversalTime()
$amzDate = $timestamp.ToString('yyyyMMddTHHmmssZ')
$dateStamp = $timestamp.ToString('yyyyMMdd')
$hostHeader = $uri.Host
if (-not $uri.IsDefaultPort) { $hostHeader = "${hostHeader}:$($uri.Port)" }
$canonicalUri = BuildCanonicalPath $uri
$canonicalQueryString = ''
$canonicalHeaders = "host:$hostHeader`n" + "x-amz-content-sha256:$payloadHash`n" + "x-amz-date:$amzDate`n"
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
$canonicalRequest = "$method`n$canonicalUri`n$canonicalQueryString`n$canonicalHeaders`n$signedHeaders`n$payloadHash"
$credentialScope = "$dateStamp/$region/s3/aws4_request"
$stringToSign = "$algorithm`n$amzDate`n$credentialScope`n$((Get-HashHex (Get-Bytes $canonicalRequest)))"
$signingKey = GetSignatureKey $secretKey $dateStamp $region 's3'
$signature = ToHex (HmacSha256 $signingKey $stringToSign)
$authHeader = "$algorithm Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature"
@{
'x-amz-date' = $amzDate
'x-amz-content-sha256' = $payloadHash
'Authorization' = $authHeader
}
}
function BuildS3Uri([string]$endpointUrl, [string]$bucket, [string]$key, [bool]$forcePathStyle) {
$base = [System.Uri]$endpointUrl
$builder = [System.UriBuilder]::new($base)
$normalizedKey = $key.Replace('\', '/').TrimStart('/')
if ($forcePathStyle) {
$path = $builder.Path.TrimEnd('/')
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
$builder.Path = ($path.TrimEnd('/') + '/' + $bucket + '/' + $normalizedKey)
} else {
$builder.Host = "$bucket." + $builder.Host
$path = $builder.Path.TrimEnd('/')
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
$builder.Path = ($path.TrimEnd('/') + '/' + $normalizedKey)
}
return $builder.Uri
}
function Ensure-AWSS3Module {
try { $null = [Amazon.S3.AmazonS3Client]; return } catch {}
try { Import-Module -Name AWS.Tools.S3 -ErrorAction Stop; return } catch {}
try { Import-Module -Name AWSPowerShell.NetCore -ErrorAction Stop; return } catch {}
throw "AWS Tools for PowerShell not found. Install with: Install-Module AWS.Tools.S3 -Scope CurrentUser"
}
function New-S3Client {
param(
[string]$EndpointUrl,
[string]$Region,
[string]$AccessKeyId,
[string]$SecretAccessKey,
[bool]$ForcePathStyle = $true
)
Ensure-AWSS3Module
$creds = New-Object Amazon.Runtime.BasicAWSCredentials($AccessKeyId, $SecretAccessKey)
$cfg = New-Object Amazon.S3.AmazonS3Config
if ($EndpointUrl) { $cfg.ServiceURL = $EndpointUrl }
if ($Region) { try { $cfg.RegionEndpoint = [Amazon.RegionEndpoint]::GetBySystemName($Region) } catch {} }
$cfg.ForcePathStyle = [bool]$ForcePathStyle
return (New-Object Amazon.S3.AmazonS3Client($creds, $cfg))
}
# ---------------------------------------------------------------------------
# Active Directory credential and permission helpers
# (requires the ActiveDirectory module to be loaded before calling)
# ---------------------------------------------------------------------------
function Get-ValidatedADCredential {
param (
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$Server,
[int]$MaxAttempts = 3
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $DomainName (attempt $attempt/$MaxAttempts)"
if ($null -eq $credential) {
throw "Credential prompt was cancelled."
}
try {
Get-ADDomain -Server $Server -Credential $credential -ErrorAction Stop | Out-Null
Write-Verbose ("Credential pre-check succeeded for '{0}' against '{1}'." -f $credential.UserName, $Server)
return $credential
} catch {
$message = $_.Exception.Message
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
Write-Warning ("Credentials were rejected for '{0}' (attempt {1}/{2})." -f $credential.UserName, $attempt, $MaxAttempts)
if ($attempt -lt $MaxAttempts) { continue }
throw "Credentials were rejected by domain controller '$Server' after $MaxAttempts attempts."
}
throw "Credential pre-check failed against '$Server': $message"
}
}
}
function Test-ReplicationPermissions {
param(
[Parameter(Mandatory)][string]$DomainDN,
[Parameter(Mandatory)][string]$Server,
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
)
$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() }
}
}
+61 -21
View File
@@ -7,7 +7,7 @@
##################################################
## Project: Elysium ##
## File: Elysium.ps1 ##
## Version: 1.2.0 ##
## Version: 2.4.6 ##
## Support: support@cqre.net ##
##################################################
@@ -21,13 +21,13 @@ Elysium.ps1 offers a menu to perform various actions:
2. Test Weak AD Passwords
3. Extract and Send Current Hashes for KHDB Update
4. Uninstall the tool
5. Exit
5. Update Lithnet Password Protection store
6. Exit
#>
# Safer defaults
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# Define the path to the settings file
$settingsFilePath = Join-Path -Path $PSScriptRoot -ChildPath "ElysiumSettings.txt"
@@ -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)
@@ -69,6 +76,20 @@ function Start-OrchestratorTranscript {
function Stop-OrchestratorTranscript { try { Stop-Transcript | Out-Null } catch {} }
function Invoke-WindowsPowerShellScript {
param([string]$ScriptPath)
$powershellCmd = Get-Command -Name 'powershell.exe' -ErrorAction SilentlyContinue
if (-not $powershellCmd) {
throw "Windows PowerShell (powershell.exe) was not found. Install it or run the script from a Desktop edition session."
}
$args = @('-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath)
& $powershellCmd.Path @args
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw ("Windows PowerShell script '{0}' exited with code {1}." -f $ScriptPath, $exitCode)
}
}
function Show-Menu {
param (
[string]$Title = 'Elysium Tool Main Menu'
@@ -78,8 +99,9 @@ function Show-Menu {
Write-Host "1: Download/Update Known-Hashes Database (KHDB)"
Write-Host "2: Test Weak AD Passwords"
Write-Host "3: Extract and Send Current Hashes for KHDB Update"
Write-Host "4: Uninstall"
Write-Host "5: Exit"
Write-Host "4: Update Lithnet Password Protection Store"
Write-Host "5: Uninstall"
Write-Host "6: Exit"
}
Start-OrchestratorTranscript -BasePath $PSScriptRoot
@@ -87,6 +109,7 @@ try {
do {
Show-Menu
$userSelection = Read-Host "Please make a selection"
try {
switch ($userSelection) {
'1' {
Write-Host "Downloading KHDB..."
@@ -94,27 +117,44 @@ do {
}
'2' {
Write-Host "Testing Weak AD Passwords..."
& (Join-Path -Path $PSScriptRoot -ChildPath 'Test-WeakADPasswords.ps1')
$testScript = Join-Path -Path $PSScriptRoot -ChildPath 'Test-WeakADPasswords.ps1'
if ($PSVersionTable.PSEdition -eq 'Desktop') {
& $testScript
} else {
Invoke-WindowsPowerShellScript -ScriptPath $testScript
}
}
'3' {
Write-Host "Extracting and Sending Current Hashes..."
& (Join-Path -Path $PSScriptRoot -ChildPath 'Extract-NTHashes.ps1')
$extractScript = Join-Path -Path $PSScriptRoot -ChildPath 'Extract-NTHashes.ps1'
if ($PSVersionTable.PSEdition -eq 'Desktop') {
& $extractScript
} else {
Invoke-WindowsPowerShellScript -ScriptPath $extractScript
}
}
'4' {
Write-Host "Updating Lithnet Password Protection store..."
& (Join-Path -Path $PSScriptRoot -ChildPath 'Update-LithnetStore.ps1')
}
'5' {
Write-Host "Uninstalling..."
& (Join-Path -Path $PSScriptRoot -ChildPath 'Uninstall.ps1')
}
'5' {
'6' {
Write-Host "Exiting..."
# end loop; transcript will be stopped after the loop
$userSelection = '5'
$userSelection = '6'
}
default {
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 '5')
} while ($userSelection -ne '6')
} finally {
Stop-OrchestratorTranscript
}
+28 -1
View File
@@ -8,7 +8,7 @@
##################################################
## Project: Elysium ##
## File: ElysiumSettings.txt ##
## Version: 1.1.0 ##
## Version: 2.4.4 ##
## Support: support@cqre.net ##
##################################################
@@ -36,6 +36,14 @@ s3SecretAccessKey =
s3ForcePathStyle = true
s3UseAwsTools = false
# KHDB Shard Settings
#####################
# The KHDB update script downloads a manifest plus per-prefix shards (default shard size 2).
# These values control the remote object names and local storage directory.
KhdbManifestPath=khdb/manifest.json
KhdbShardPrefix=khdb/shards
KhdbLocalShardDir=khdb-shards
# Application Settings
######################
InstallationPath=
@@ -43,6 +51,25 @@ ReportPathBase=Reports
WeakPasswordsDatabase=khdb.txt
# CheckOnlyEnabledUsers=true
# Lithnet Password Protection Settings
######################################
LithnetStorePath=
LithnetSyncHibp=false
LithnetHashSources=khdb.txt
LithnetPlaintextSources=
LithnetBannedWordSources=
# Telemetry (optional)
######################
# These values are empty by default so no telemetry is sent.
# Provide a pre-signed URL (for example, an S3 PUT) to receive a single beacon
# when the weak-password test starts. Only script name, version, and timestamp
# are transmitted; you can set UsageBeaconInstanceId to differentiate deployments.
UsageBeaconUrl=
UsageBeaconMethod=GET # GET, POST, or PUT
UsageBeaconInstanceId=
UsageBeaconTimeoutSeconds=5
# Notes:
# - Required PowerShell modules: DSInternals, ActiveDirectory
# For Azure uploads: Az.Storage
+259 -287
View File
@@ -6,8 +6,8 @@
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
##################################################
## Project: Elysium ##
## File: Extract-NTLMHashes.ps1 ##
## Version: 1.2.0 ##
## File: Extract-NTHashes.ps1 ##
## Version: 2.4.6 ##
## Support: support@cqre.net ##
##################################################
@@ -22,8 +22,22 @@ 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 {
@@ -39,156 +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 { return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() } 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 { return $h.ComputeHash((Get-Bytes $data)) } 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 = "$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
@@ -206,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 ''))
@@ -224,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 (
@@ -243,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
@@ -257,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,
@@ -297,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
$DomainDetails = @{}
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() | 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 {
File diff suppressed because it is too large Load Diff
+89 -6
View File
@@ -12,27 +12,110 @@ 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:** A domain user account with Domain Admin privileges for each tested AD domain. This account should be active only during testing.
* **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
Elysium uses a **unified project version** starting with v2.2.0. Every script, the settings template, and the documentation share the same version number so you can verify consistency at a glance by checking the header of any `.ps1` file. Releases are tagged in Git (`v<major>.<minor>.<patch>`) and documented in `CHANGELOG.md`.
Prior to v2.2.0, each script carried its own version number; those historical versions are preserved in the changelog for reference.
---
## Operation
### Install and update
This tool is provided in private git repository. Installation and updating is done with cloning and pulling from this repository.
During first run, the tool will ask for passphrase that will be used to encrypt/decrypt sensitive content.
After installation, edit ElysiumSettings.txt, check all variables and add domains to test.
All scripts automatically relaunch under PowerShell 7 (`pwsh`) when it is installed so that features like parallel transfers are available; if pwsh is missing they continue under Windows PowerShell 5.1 with the legacy single-threaded behavior. The two DSInternals-driven workflows (menu options 2 and 3) load the legacy `ActiveDirectory` and `DSInternals` modules, so they automatically fall back to Windows PowerShell even if pwsh is present.
### Update Known-Hashed Database (KHDB)
Run script Elysium.ps1 as an administrator and choose option 1 (Update Known-Hashes Database).
The script downloads the database from the configured storage (Azure Blob or S3-compatible), decompresses it and updates the current database.
The updater now pulls a manifest plus individual hash shards (two-hex prefix layout) from the configured storage (Azure Blob or S3-compatible), verifies checksums, replaces only changed shards, and rebuilds `khdb.txt` for local use. Deleted shards listed in the manifest are removed automatically. When PowerShell 7 is available the downloader automatically fetches up to `-MaxParallelTransfers` shards in parallel (default `5`); on Windows PowerShell 5.1 it reverts to the original sequential behavior. Override the concurrency as needed when running the script directly (for example `.\Update-KHDB.ps1 -MaxParallelTransfers 8`).
To publish an updated shard set, run `Prepare-KHDBStorage.ps1` against your sorted `khdb.txt` (or point it at the directory/list of the Have I Been Pwned `.gz` slices). The helper reconstructs the full 32hex NTLM values (prefix + remainder), deduplicates per hash (keeping the largest count), splits by the first two hex characters, writes a manifest (`version`, `sha256`, `size`, entry counts), and can upload the resulting files directly to Azure Blob Storage (via SAS) or S3-compatible endpoints using SigV4. Invalid or malformed entries are omitted automatically, and a short report (aggregate counts + `invalid-hashes.txt`) is produced for review. Example:
```powershell
.\Prepare-KHDBStorage.ps1 -SourcePath .\khdb.txt `
-OutputRoot .\publish `
-StorageProvider S3 `
-S3EndpointUrl https://s3.example.com `
-S3BucketName private-khdb `
-S3AccessKeyId AKIA... `
-S3SecretAccessKey ... `
-ManifestRemotePath khdb/manifest.json `
-ShardRemotePrefix khdb/shards
```
Use `-SkipUpload` to stage files locally, or `-StorageProvider Azure` with `storageAccountName`/`containerName`/`sasToken` when targeting Azure Blob Storage. Add `-ShowProgress` (optionally tune `-ProgressUpdateInterval`) if you want a running `Write-Progress` indicator while the hashes are being split. Pass `-ForcePlainText` when your `khdb.txt` already contains complete hashes and you want `.gz` references treated as invalid instead of being expanded. When you only need to push an already prepared package, combine `-UploadOnly` with `-OutputRoot` pointing at the existing shard directory and choose the storage provider to perform an upload-only run. Missing storage values are pulled from `ElysiumSettings.txt` automatically (override the path with `-SettingsPath`) so you dont have to retype S3/Azure credentials for every run. On PowerShell 7, `Prepare-KHDBStorage.ps1` can push shards concurrently by setting `-MaxParallelTransfers` (default `5`); Windows PowerShell 5.1 automatically falls back to serial uploads.
Every run also emits a cleaned, DSInternals-friendly `khdb-clean.txt` beside the shards so you can inspect or distribute the merged list before publishing.
When `-ForcePlainText` is specified the script automatically keeps a checkpoint (default: `<output>/khdb.checkpoint.json`) and resumes from it on the next run so massive inputs dont restart from scratch. Use `-CheckpointPath` to relocate that file or `-NoCheckpoint` to disable the behavior entirely.
### Test Weak AD passwords
Run script Elysium.ps1 as an administrator and choose option 2 (Test Weak AD Passwords).
The script will then ask for the domain to be tested and upon choice will ask for domain administrator password. The DA username is already provided in the script for each domain.
The tool then connects to Domain Controller and tests all enabled users in the domain against KHDB. PDF report with findings is then generated.
The script lists domains in the same order as they appear in `ElysiumSettings.txt`. After you pick one, it prompts for credentials and validates them against the selected domain controller before running the password-quality test.
The tool connects to the selected Domain Controller and compares accounts against KHDB (respecting the optional `CheckOnlyEnabledUsers` flag if configured). A timestamped text report is saved under `Reports`, and accounts with dictionary hits are also exported to a dedicated UPN-only text file to support follow-up automation.
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 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` (required on 2008 R2+ to read password hashes)
**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.
- `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.
#### Optional usage beacon
If you want to know the script was executed without collecting telemetry, set a pre-signed URL (for example, an S3 `PUT` URL) in `UsageBeaconUrl` inside `ElysiumSettings.txt`. When present, the weak-password script issues a single request as soon as it loads the settings. Only the script name, its version, a UTC timestamp, and the optional `UsageBeaconInstanceId` value are sent, and network failures never block the run. Choose the HTTP verb via `UsageBeaconMethod` (`GET`, `POST`, or `PUT`) and adjust the timeout with `UsageBeaconTimeoutSeconds` if your storage endpoint needs more time.
### Send current hashes for update KHDB
Run script Elysium.ps1 as an administrator and choose option 3 (Extract and Send Hashes).
The tool will then ask for domain and password of domain administrator. With correct credentials, the tool will then extract current hashes (no history) of non-disabled users, compresses and encrypts them and uploads them to the configured storage (Azure Blob or S3-compatible) for pickup by the tool provider.
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.
S3-compatible usage notes:
- No AWS Tools required. The scripts can sign requests using native SigV4 via .NET and HttpClient.
- No AWS Tools required. The scripts sign requests using native SigV4 via .NET and HttpClient, including non-default endpoint ports.
- To force using AWS Tools instead, set `s3UseAwsTools = true` in `ElysiumSettings.txt` and install `AWS.Tools.S3`.
### Uninstallation
Run script Elysium.ps1 as an administrator and choose option 4 (Uninstall).
+413 -54
View File
@@ -8,7 +8,7 @@
##################################################
## Project: Elysium ##
## File: Test-WeakADPasswords.ps1 ##
## Version: 1.3.0 ##
## Version: 2.4.6 ##
## Support: support@cqre.net ##
##################################################
@@ -21,10 +21,14 @@ 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
$VerbosePreference = "Continue"
[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
@@ -52,6 +56,63 @@ function Start-TestTranscript {
function Stop-TestTranscript { try { Stop-Transcript | Out-Null } catch {} }
function Invoke-UsageBeacon {
param(
[string]$Url,
[string]$Method = 'GET',
[int]$TimeoutSeconds = 5,
[string]$InstanceId
)
if ([string]::IsNullOrWhiteSpace($Url)) { return }
$normalizedMethod = 'GET'
if (-not [string]::IsNullOrWhiteSpace($Method)) {
$normalizedMethod = $Method.ToUpperInvariant()
}
if ($normalizedMethod -notin @('GET', 'POST', 'PUT')) {
$normalizedMethod = 'GET'
}
$requestParams = @{
Uri = $Url
Method = $normalizedMethod
ErrorAction = 'Stop'
}
$invokeWebRequestCmd = $null
try { $invokeWebRequestCmd = Get-Command -Name Invoke-WebRequest -ErrorAction Stop } catch { }
if ($invokeWebRequestCmd -and $invokeWebRequestCmd.Parameters.ContainsKey('UseBasicParsing')) {
$requestParams['UseBasicParsing'] = $true
}
if ($TimeoutSeconds -gt 0 -and $invokeWebRequestCmd -and $invokeWebRequestCmd.Parameters.ContainsKey('TimeoutSec')) {
$requestParams['TimeoutSec'] = $TimeoutSeconds
}
if (-not [string]::IsNullOrWhiteSpace($InstanceId)) {
$requestParams['Headers'] = @{ 'X-Elysium-Instance' = $InstanceId }
}
if ($normalizedMethod -in @('POST', 'PUT')) {
$payload = [ordered]@{
script = 'Test-WeakADPasswords'
version = $ElysiumVersion
ranAtUtc = (Get-Date).ToUniversalTime().ToString('o')
}
if (-not [string]::IsNullOrWhiteSpace($InstanceId)) {
$payload['instanceId'] = $InstanceId
}
$requestParams['ContentType'] = 'application/json'
$requestParams['Body'] = ($payload | ConvertTo-Json -Depth 3 -Compress)
}
try {
Invoke-WebRequest @requestParams | Out-Null
Write-Verbose ("Usage beacon sent via {0}." -f $normalizedMethod)
} catch {
Write-Verbose ("Usage beacon failed: {0}" -f $_.Exception.Message)
}
}
# Current timestamp for both report generation and header
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
@@ -66,31 +127,32 @@ $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"
$ElysiumSettings = Read-ElysiumSettings -ScriptRoot $scriptRoot
Write-Verbose "Settings loaded successfully."
# 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()
}
}
$usageBeaconUrl = $ElysiumSettings['UsageBeaconUrl']
$usageBeaconMethod = $ElysiumSettings['UsageBeaconMethod']
$usageBeaconInstanceId = $ElysiumSettings['UsageBeaconInstanceId']
$usageBeaconTimeoutSeconds = $null
if ($ElysiumSettings.ContainsKey('UsageBeaconTimeoutSeconds')) {
$parsedTimeout = 0
if ([int]::TryParse($ElysiumSettings['UsageBeaconTimeoutSeconds'], [ref]$parsedTimeout)) {
$usageBeaconTimeoutSeconds = $parsedTimeout
}
Write-Verbose "Settings loaded successfully."
} catch {
Write-Error ("An error occurred while loading settings: {0}" -f $_.Exception.Message)
exit
}
if (-not [string]::IsNullOrWhiteSpace($usageBeaconUrl)) {
$beaconParams = @{ Url = $usageBeaconUrl }
if (-not [string]::IsNullOrWhiteSpace($usageBeaconMethod)) {
$beaconParams['Method'] = $usageBeaconMethod
}
if (-not [string]::IsNullOrWhiteSpace($usageBeaconInstanceId)) {
$beaconParams['InstanceId'] = $usageBeaconInstanceId
}
if ($null -ne $usageBeaconTimeoutSeconds) {
$beaconParams['TimeoutSeconds'] = $usageBeaconTimeoutSeconds
}
Invoke-UsageBeacon @beaconParams
}
# Define the function to extract domain details from settings
@@ -99,7 +161,7 @@ function Get-DomainDetailsFromSettings {
[hashtable]$Settings
)
$domainDetails = @{}
$domainDetails = [ordered]@{}
$counter = 1
while ($true) {
$nameKey = "Domain${counter}Name"
@@ -135,6 +197,28 @@ if ($runningInPSCore -and -not $onWindows) {
throw 'This script requires Windows when running under PowerShell 7 (AD/DSInternals are Windows-only).'
}
function Test-IsFipsPolicyEnabled {
if (-not $onWindows) { return $false }
try {
$fipsReg = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy' -Name Enabled -ErrorAction Stop
return ([int]$fipsReg.Enabled -eq 1)
} catch {
return $false
}
}
if (Test-IsFipsPolicyEnabled) {
throw @"
FIPS policy is enabled on this host (HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy\Enabled = 1).
Test-WeakADPasswords uses DSInternals/AD replication operations that are not fully compatible with this policy in this environment.
Remediation:
1. Run this script from a dedicated non-FIPS workstation/jump host.
2. If approved by your security policy, temporarily disable local FIPS policy for this host, run the test, then re-enable it.
3. If FIPS must remain enforced, use an alternative fully FIPS-validated workflow/tool for weak password assessment.
"@
}
function Test-IsAdmin {
try {
$wi = [Security.Principal.WindowsIdentity]::GetCurrent()
@@ -237,6 +321,51 @@ function Import-CompatModule {
$params['UseWindowsPowerShell'] = $true
}
}
if ($Name -eq 'DSInternals') {
# DSInternals can emit a FIPS MD5 warning via Write-Error during import; treat it as non-fatal if the module loads.
$params['ErrorAction'] = 'SilentlyContinue'
$importErrors = @()
try {
Import-Module @params -ErrorVariable +importErrors
} catch {
$fqid = [string]$_.FullyQualifiedErrorId
$message = $_.Exception.Message
$isFipsBootstrapError = ($fqid -match 'DSInternals\.Bootstrap\.psm1') -and ($message -match 'Only FIPS certified cryptographic algorithms are enabled in \.NET')
if (-not $isFipsBootstrapError) { throw }
Write-Warning "DSInternals bootstrap reported FIPS restrictions. Continuing if the module is available."
}
$moduleLoaded = [bool](Get-Module -Name $Name -ErrorAction SilentlyContinue)
if (-not $moduleLoaded) {
$fipsErrorSeen = @($importErrors | Where-Object { $_.Exception.Message -match 'Only FIPS certified cryptographic algorithms are enabled in \.NET' }).Count -gt 0
if ($fipsErrorSeen) {
throw "DSInternals could not be loaded under current FIPS policy. Use a host/policy that allows required algorithms for DSInternals."
}
if ($importErrors.Count -gt 0) { throw $importErrors[0] }
throw "Failed to import module '$Name'."
}
$fipsErrors = @($importErrors | Where-Object { $_.Exception.Message -match 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
if ($fipsErrors.Count -gt 0) {
Write-Warning "DSInternals loaded under FIPS policy. MD5-dependent DSInternals checks may be limited."
}
$nonFipsErrors = @($importErrors | Where-Object { $_.Exception.Message -notmatch 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
if ($nonFipsErrors.Count -gt 0) {
$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)
return
}
Import-Module @params
Write-Verbose ("Imported module '{0}' (Core={1}, Windows={2})" -f $Name, $runningInPSCore, $onWindows)
}
@@ -263,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 }
@@ -320,17 +471,117 @@ function Get-UserUPN {
# (removed stray top-level loop; UPN enrichment happens during report generation below)
function Resolve-DSInternalsWeakHashFile {
param(
[Parameter(Mandatory)][string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Weak password hashes file not found at '$Path'."
}
$compatibleRegex = '^[0-9A-F]{32}$'
$legacyRegex = '^[0-9A-Fa-f]{32}(:\d+)?$'
$lineNumber = 0
$previousHash = $null
$duplicateCount = 0
$legacyEntryCount = 0
$needsNormalization = $false
$reader = $null
try {
$reader = New-Object System.IO.StreamReader($Path, [System.Text.Encoding]::UTF8, $true)
while (($line = $reader.ReadLine()) -ne $null) {
$lineNumber++
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0) { continue }
if ($trimmed -notmatch $legacyRegex) {
throw ("Weak password hashes file '{0}' contains invalid content at line {1}: '{2}'." -f $Path, $lineNumber, $trimmed)
}
if ($trimmed -notmatch $compatibleRegex) {
$needsNormalization = $true
if ($trimmed.Contains(':')) { $legacyEntryCount++ }
}
$normalizedHash = ($trimmed.Split(':', 2)[0]).ToUpperInvariant()
if ($line -cne $trimmed -or $trimmed -cne $normalizedHash) {
$needsNormalization = $true
}
if ($null -ne $previousHash) {
if ($normalizedHash -lt $previousHash) {
throw "Weak password hashes file '$Path' is not sorted alphabetically at line $lineNumber."
}
if ($normalizedHash -eq $previousHash) {
$duplicateCount++
$needsNormalization = $true
}
}
$previousHash = $normalizedHash
}
} finally {
if ($reader) { $reader.Dispose() }
}
if (-not $needsNormalization) {
return [pscustomobject]@{
Path = $Path
IsTemporary = $false
}
}
$tmpPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), ('elysium-khdb-' + [System.Guid]::NewGuid().ToString() + '.txt'))
$encoding = New-Object System.Text.UTF8Encoding($false)
$reader = $null
$writer = $null
$lastWrittenHash = $null
try {
$reader = New-Object System.IO.StreamReader($Path, [System.Text.Encoding]::UTF8, $true)
$writer = New-Object System.IO.StreamWriter($tmpPath, $false, $encoding)
while (($line = $reader.ReadLine()) -ne $null) {
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0) { continue }
$normalizedHash = ($trimmed.Split(':', 2)[0]).ToUpperInvariant()
if ($normalizedHash -eq $lastWrittenHash) { continue }
$writer.WriteLine($normalizedHash)
$lastWrittenHash = $normalizedHash
}
} finally {
if ($reader) { $reader.Dispose() }
if ($writer) { $writer.Dispose() }
}
$normalizationReasons = @()
if ($legacyEntryCount -gt 0) { $normalizationReasons += "$legacyEntryCount legacy HASH:count entries" }
if ($duplicateCount -gt 0) { $normalizationReasons += "$duplicateCount duplicate hashes" }
if ($normalizationReasons.Count -eq 0) { $normalizationReasons += 'format normalization' }
Write-Warning ("Normalized weak password hashes file for DSInternals compatibility ({0}). Temporary file: {1}" -f ($normalizationReasons -join ', '), $tmpPath)
return [pscustomobject]@{
Path = $tmpPath
IsTemporary = $true
}
}
# Function to test for weak AD passwords
function Test-WeakADPasswords {
param (
[hashtable]$DomainDetails,
[string]$FilePath,
[bool]$CheckOnlyEnabledUsers = $false
[bool]$CheckOnlyEnabledUsers = $false,
[System.Management.Automation.PSCredential]$Credential
)
# User selects a domain
Write-Host "Select a domain to test:"
$DomainDetails.GetEnumerator() | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" }
$DomainDetails.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" }
$selection = Read-Host "Enter the number of the domain"
if (-not ($DomainDetails.ContainsKey($selection))) {
@@ -341,12 +592,34 @@ function Test-WeakADPasswords {
$selectedDomain = $DomainDetails[$selection]
Write-Verbose "Selected domain: $($selectedDomain.Name)"
# Prompt for DA credentials
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $($selectedDomain.Name)"
if ([string]::IsNullOrWhiteSpace($selectedDomain["DC"])) {
Write-Error ("Domain '{0}' does not have a configured DC in ElysiumSettings.txt." -f $selectedDomain.Name)
return
}
if ($null -eq $Credential) {
$credential = Get-ValidatedADCredential -DomainName $selectedDomain.Name -Server $selectedDomain["DC"]
} else {
$credential = $Credential
Write-Verbose ("Using credential supplied by caller: {0}" -f $credential.UserName)
}
# 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 {
Write-Error $_.Exception.Message
return
}
# Performing the test
Write-Verbose "Testing password quality for $($selectedDomain.Name)..."
$resolvedHashFile = $null
try {
$resolvedHashFile = Resolve-DSInternalsWeakHashFile -Path $FilePath
$accounts = Get-ADReplAccount -All -Server $selectedDomain["DC"] -Credential $credential
if ($CheckOnlyEnabledUsers) {
Write-Verbose "Filtering to only enabled users per settings."
@@ -355,50 +628,136 @@ function Test-WeakADPasswords {
if ($_.PSObject.Properties.Name -contains 'Enabled') { $_.Enabled } else { $true }
}
}
$testResults = $accounts | Test-PasswordQuality -WeakPasswordHashesFile $FilePath
$testResults = $accounts | Test-PasswordQuality -WeakPasswordHashesSortedFile $resolvedHashFile.Path
Write-Verbose "Password quality test completed."
} catch {
Write-Error ("An error occurred while testing passwords: {0}" -f $_.Exception.Message)
$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++
}
$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"])
} else {
Write-Error ("An error occurred while testing passwords: {0}" -f $message)
}
return
} finally {
if ($resolvedHashFile -and $resolvedHashFile.IsTemporary -and (Test-Path -LiteralPath $resolvedHashFile.Path)) {
try { Remove-Item -LiteralPath $resolvedHashFile.Path -Force -ErrorAction Stop } catch { }
}
}
# Report generation with dynamic content and UPNs
$reportPath = Join-Path -Path $reportPathBase -ChildPath "$($selectedDomain.Name)_WeakPasswordReport_$timestamp.txt"
$upnOnlyReportPath = Join-Path -Path $reportPathBase -ChildPath "$($selectedDomain.Name)_DictionaryPasswordUPNs_$timestamp.txt"
Write-Verbose "Generating report at $reportPath"
$reportContent = @($header, ($testResults | Out-String).Trim(), $footer) -join "`r`n"
$lines = $reportContent -split "`r`n"
$newReportContent = @()
# Build a lookup of SAM account names to UPNs for dictionary hits by leveraging structured results
$dictionaryLogonNames = @()
foreach ($result in @($testResults)) {
if ($null -ne $result -and $null -ne $result.WeakPassword) {
$dictionaryLogonNames += $result.WeakPassword
}
}
$dictionaryLogonNames = $dictionaryLogonNames | Sort-Object -Unique
$dictionarySamToUpn = @{}
$upnReportContent = @()
foreach ($logonName in $dictionaryLogonNames) {
$samAccountName = $logonName -replace '^.*\\', ''
if (-not [string]::IsNullOrWhiteSpace($samAccountName) -and -not $dictionarySamToUpn.ContainsKey($samAccountName)) {
Write-Verbose "Looking up UPN for $samAccountName (dictionary hit)"
$upn = Get-UserUPN -SamAccountName $samAccountName -Domain $selectedDomain.DC -Credential $credential
$dictionarySamToUpn[$samAccountName] = $upn
if ($upn -ne "UPN not found") {
$upnReportContent += $upn
}
}
}
Write-Verbose "Generating report at $reportPath"
# -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 = @()
$collectingUPNs = $false
foreach ($line in $lines) {
$newReportContent += $line
# Start collecting UPNs after detecting the relevant section in the report
if ($line -match "Passwords of these accounts have been found in the dictionary:") {
$collectingUPNs = $true
continue
}
# Stop collecting UPNs if a new section starts or end of section is detected
if ($collectingUPNs -and $line -match "^\s*$") {
$collectingUPNs = $false
}
if ($collectingUPNs) {
if ($line -match '^\s*$') { continue }
if ($line -match '^\s*-{2,}') { continue }
if ($line -match '^\s*(SamAccountName|LogonName)\b') { continue }
if ($line -match '^[^\s].*:\s*$' -and $line -notmatch 'dictionary') {
$collectingUPNs = $false
continue
}
# Regex to match the SAMAccountName from the report line and collect UPNs if in the target section
if ($collectingUPNs -and $line -match "^\s*(\S+)\s*$") {
$samAccountName = $matches[1]
Write-Verbose "Looking up UPN for $samAccountName"
$upn = Get-UserUPN -SamAccountName $samAccountName -Domain $selectedDomain.DC -Credential $credential
$newReportContent += " UPN: $upn"
# Collect UPNs only for accounts found in the dictionary section
if ($upn -ne "UPN not found") {
$upnReportContent += $upn
$firstToken = ($line.Trim() -split '\s+')[0]
if (-not [string]::IsNullOrWhiteSpace($firstToken)) {
$samAccountName = $firstToken -replace '^.*\\', ''
if ($dictionarySamToUpn.ContainsKey($samAccountName)) {
$upnValue = $dictionarySamToUpn[$samAccountName]
$newReportContent += " UPN: $upnValue"
}
}
}
}
+14 -13
View File
@@ -7,7 +7,7 @@
##################################################
## Project: Elysium ##
## File: Uninstall.ps1 ##
## Version: 1.1.0 ##
## Version: 2.4.6 ##
## Support: support@cqre.net ##
##################################################
@@ -19,6 +19,13 @@ Uninstall script for the Elysium AD password testing tool.
This script will remove the Elysium tool and its components (scripts, configurations, and any generated data) from the system, and then delete itself.
#>
$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
Restart-WithPwshIfAvailable -BoundParameters $PSBoundParameters -UnboundArguments $MyInvocation.UnboundArguments
function Start-UninstallTranscript {
try {
$base = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'Elysium', 'logs')
@@ -34,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
+667 -199
View File
@@ -7,32 +7,34 @@
##################################################
## Project: Elysium ##
## File: Update-KHDB.ps1 ##
## Version: 1.1.0 ##
## Version: 2.4.6 ##
## Support: support@cqre.net ##
##################################################
<#
.SYNOPSIS
Known hashes database update script for the Elysium AD password testing tool.
Known-hashes database updater for the Elysium AD password testing tool.
.DESCRIPTION
This script downloads khdb.txt.zip from the designated storage (Azure Blob or S3-compatible), validates and decompresses it, and atomically updates the current version with backup and logging.
Downloads a sharded KHDB manifest, performs incremental shard updates, validates
checksums, and atomically refreshes the merged khdb.txt for downstream scripts.
Supports Azure Blob Storage (via SAS) and S3-compatible endpoints (SigV4).
#>
# safer defaults
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# ensure TLS 1.2
[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-WithPwshIfAvailable -BoundParameters $PSBoundParameters -UnboundArguments $MyInvocation.UnboundArguments
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
# Resolve paths
$scriptRoot = $PSScriptRoot
function Start-UpdateTranscript {
param(
[string]$BasePath
)
param([string]$BasePath)
try {
$logsDir = Join-Path -Path $BasePath -ChildPath 'Reports/logs'
if (-not (Test-Path $logsDir)) { New-Item -Path $logsDir -ItemType Directory -Force | Out-Null }
@@ -48,19 +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 }
@@ -69,136 +58,36 @@ function Get-InstallationPath([hashtable]$settings) {
}
function New-HttpClient {
Add-Type -AssemblyName System.Net.Http
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
$client = [System.Net.Http.HttpClient]::new()
$client.Timeout = [TimeSpan]::FromSeconds(600)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('Elysium/1.1 (+Update-KHDB)')
$client.DefaultRequestHeaders.UserAgent.ParseAdd("Elysium/$ElysiumVersion (+Update-KHDB)")
return $client
}
function Build-BlobUri([string]$account, [string]$container, [string]$sas) {
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.' }
$sas = $sas.Trim()
if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
$ub = [System.UriBuilder]::new("https://$account.blob.core.windows.net/$container/khdb.txt.zip")
$ub.Query = $sas.TrimStart('?')
return $ub.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 New-S3Client {
function Invoke-DownloadWithRetry {
param(
[string]$EndpointUrl,
[string]$Region,
[string]$AccessKeyId,
[string]$SecretAccessKey,
[bool]$ForcePathStyle = $true
[System.Net.Http.HttpClient]$Client,
[string]$Uri,
[string]$TargetPath,
[string]$Activity
)
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 { ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } }
function HmacSha256([byte[]]$key, [string]$data) { $h=[System.Security.Cryptography.HMACSHA256]::new($key); try { $h.ComputeHash((Get-Bytes $data)) } 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','/' }; $enc }
function BuildCanonicalPath([System.Uri]$uri) { $segments=$uri.AbsolutePath.Split('/'); $encoded=@(); foreach($s in $segments){ $encoded += (UriEncode $s $false) }; $p=($encoded -join '/'); if (-not $p.StartsWith('/')){ $p='/' + $p }; $p }
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'
$amzdate = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
$datestamp = (Get-Date).ToUniversalTime().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; $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) }
$ub.Uri
}
function Invoke-S3HttpDownloadWithRetry([string]$endpointUrl, [string]$bucket, [string]$key, [string]$targetPath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
$client = [System.Net.Http.HttpClient]::new()
$retries=5; $delay=2
try {
for($i=0;$i -lt $retries;$i++){
try {
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
$payloadHash = (Get-HashHex (Get-Bytes ''))
$req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri)
$hdrs = BuildAuthHeaders -method 'GET' -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
if (-not $resp.IsSuccessStatusCode) { throw "HTTP $([int]$resp.StatusCode) $($resp.ReasonPhrase)" }
$totalBytes = $resp.Content.Headers.ContentLength
$stream = $resp.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 "Downloading khdb.txt.zip" -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct } else { Write-Progress -Activity "Downloading khdb.txt.zip" -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0 }
}
} finally { $fs.Close(); $stream.Close() }
if ($resp) { $resp.Dispose() }
return
} catch {
if ($i -lt ($retries - 1)) { Write-Warning "Download failed (attempt $($i+1)/$retries): $($_.Exception.Message). Retrying in ${delay}s..."; Start-Sleep -Seconds $delay; $delay=[Math]::Min($delay*2,30) }
else { throw }
} finally { if ($req){ $req.Dispose() } }
}
} finally { $client.Dispose() }
}
function Invoke-DownloadWithRetry([System.Net.Http.HttpClient]$client, [string]$uri, [string]$targetPath) {
$retries = 5
$delay = 2
for ($i = 0; $i -lt $retries; $i++) {
for ($attempt = 0; $attempt -lt $retries; $attempt++) {
$response = $null
try {
$resp = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result
if (-not $resp.IsSuccessStatusCode) {
$code = [int]$resp.StatusCode
$response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result
if (-not $response.IsSuccessStatusCode) {
$code = [int]$response.StatusCode
if (($code -ge 500 -and $code -lt 600) -or $code -eq 429 -or $code -eq 408) { throw "Transient HTTP error $code" }
throw "HTTP error $code"
}
$totalBytes = $resp.Content.Headers.ContentLength
$stream = $resp.Content.ReadAsStreamAsync().Result
$fs = [System.IO.File]::Create($targetPath)
$totalBytes = $response.Content.Headers.ContentLength
$stream = $response.Content.ReadAsStreamAsync().Result
$fs = [System.IO.File]::Create($TargetPath)
try {
$buffer = New-Object byte[] 8192
$totalRead = 0
@@ -207,119 +96,699 @@ function Invoke-DownloadWithRetry([System.Net.Http.HttpClient]$client, [string]$
$totalRead += $read
if ($totalBytes) {
$pct = ($totalRead * 100.0) / $totalBytes
Write-Progress -Activity "Downloading khdb.txt.zip" -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct
Write-Progress -Activity $Activity -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct
} else {
Write-Progress -Activity "Downloading khdb.txt.zip" -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0
Write-Progress -Activity $Activity -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0
}
}
} finally {
$fs.Close(); $stream.Close()
$fs.Close()
$stream.Close()
}
Write-Progress -Activity $Activity -Completed -Status 'Completed'
return
} catch {
if ($i -lt ($retries - 1)) {
Write-Warning "Download failed (attempt $($i+1)/$retries): $($_.Exception.Message). Retrying in ${delay}s..."
if ($attempt -lt ($retries - 1)) {
Write-Warning "Download of '$Uri' 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 ($response) { $response.Dispose() }
}
}
}
function Validate-KHDBFile([string]$path) {
if (-not (Test-Path $path)) { throw "Validation failed: $path not found" }
$lines = Get-Content -Path $path -Encoding UTF8
if (-not $lines -or $lines.Count -eq 0) { throw 'Validation failed: file is empty.' }
$regex = '^[0-9A-Fa-f]{32}$'
$invalid = $lines | Where-Object { $_ -notmatch $regex }
if ($invalid.Count -gt 0) {
throw ("Validation failed: {0} invalid lines detected." -f $invalid.Count)
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" }
return (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Ensure-Directory {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return }
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
}
function Get-BooleanSetting {
param(
[string]$Value,
[bool]$Default = $false
)
if ([string]::IsNullOrWhiteSpace($Value)) { return $Default }
$parsed = $Default
if ([System.Boolean]::TryParse($Value, [ref]$parsed)) { return $parsed }
return $Default
}
function Get-RelativePath {
param(
[string]$BasePath,
[string]$FullPath
)
$base = (Resolve-Path -LiteralPath $BasePath).ProviderPath
$full = (Resolve-Path -LiteralPath $FullPath).ProviderPath
if (-not $base.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
$base = $base + [System.IO.Path]::DirectorySeparatorChar
}
$baseUri = New-Object System.Uri($base, [System.UriKind]::Absolute)
$fullUri = New-Object System.Uri($full, [System.UriKind]::Absolute)
$relativeUri = $baseUri.MakeRelativeUri($fullUri)
$relativePath = [System.Uri]::UnescapeDataString($relativeUri.ToString())
return $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
}
function Load-Manifest {
param([string]$Path)
$raw = Get-Content -LiteralPath $Path -Encoding UTF8 -Raw
return $raw | ConvertFrom-Json
}
function Validate-Manifest {
param([psobject]$Manifest)
if (-not $Manifest) { throw 'Manifest is empty or invalid JSON.' }
if (-not $Manifest.shards) { throw 'Manifest does not contain a shards collection.' }
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($entry in $Manifest.shards) {
if (-not $entry) { throw 'Manifest contains null shard entries.' }
$name = [string]$entry.name
if ([string]::IsNullOrWhiteSpace($name)) { throw 'Manifest shard entry is missing name.' }
$hash = [string]$entry.sha256
if ([string]::IsNullOrWhiteSpace($hash) -or $hash.Length -ne 64) { throw "Manifest shard '$name' is missing a valid sha256." }
$sizeValue = [string]$entry.size
$sizeParsed = 0L
if (-not [long]::TryParse($sizeValue, [ref]$sizeParsed) -or $sizeParsed -lt 0) {
throw "Manifest shard '$name' has invalid size."
}
if (-not $seen.Add($name)) { throw "Manifest contains duplicate shard name '$name'." }
}
# 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)."
}
}
function Convert-KHDBLineToHash {
param(
[Parameter(Mandatory)][string]$Line,
[string]$SourceName,
[int]$LineNumber
)
$trimmed = $Line.Trim()
if ($trimmed.Length -eq 0) { return $null }
if ($trimmed -notmatch '^[0-9A-Fa-f]{32}(:\d+)?$') {
throw ("Invalid KHDB content in '{0}' at line {1}: '{2}'." -f $SourceName, $LineNumber, $trimmed)
}
return ($trimmed.Split(':', 2)[0]).ToUpperInvariant()
}
function Merge-ShardsToFile {
param(
[psobject]$Manifest,
[string]$ShardsRoot,
[string]$TargetPath
)
$encoding = New-Object System.Text.UTF8Encoding($false)
$writer = New-Object System.IO.StreamWriter($TargetPath, $false, $encoding)
$previousHash = $null
try {
foreach ($entry in ($Manifest.shards | Sort-Object name)) {
$relative = [string]$entry.name
$shardPath = Join-Path -Path $ShardsRoot -ChildPath $relative
if (-not (Test-Path -LiteralPath $shardPath)) {
throw "Missing shard on disk: $relative"
}
$reader = New-Object System.IO.StreamReader($shardPath, [System.Text.Encoding]::UTF8, $true)
$lineNumber = 0
try {
while (($line = $reader.ReadLine()) -ne $null) {
$lineNumber++
$normalizedHash = Convert-KHDBLineToHash -Line $line -SourceName $relative -LineNumber $lineNumber
if ($null -eq $normalizedHash) { continue }
if ($previousHash -and $normalizedHash -lt $previousHash) {
throw "Shard merge would produce an unsorted KHDB file at '$relative' line $lineNumber."
}
if ($normalizedHash -eq $previousHash) { continue }
$writer.WriteLine($normalizedHash)
$previousHash = $normalizedHash
}
} finally {
$reader.Dispose()
}
}
} finally {
$writer.Dispose()
}
}
function Validate-KHDBFile {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { throw "Validation failed: $Path not found." }
$regex = '^[0-9A-Fa-f]{32}$'
$lineNumber = 0
$previous = $null
$duplicates = 0
$reader = New-Object System.IO.StreamReader($Path, [System.Text.Encoding]::UTF8, $true)
try {
while (($line = $reader.ReadLine()) -ne $null) {
$lineNumber++
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0) { continue }
if ($trimmed -notmatch $regex) {
throw "Validation failed: unexpected format at line $lineNumber ('$trimmed')."
}
$normalized = $trimmed.ToUpperInvariant()
if ($previous -and $normalized -lt $previous) {
Write-Warning ("Validation warning: line {0} is out of order." -f $lineNumber)
}
if ($normalized -eq $previous) { $duplicates++ }
$previous = $normalized
}
} finally {
$reader.Dispose()
}
if ($lineNumber -eq 0) { throw 'Validation failed: KHDB file is empty.' }
if ($duplicates -gt 0) {
Write-Warning ("Validation warning: detected {0} duplicate hash entries (file remains unchanged)." -f $duplicates)
}
}
function Remove-EmptyDirectories {
param([string]$Root)
if (-not (Test-Path -LiteralPath $Root)) { return }
Get-ChildItem -LiteralPath $Root -Directory -Recurse | Sort-Object FullName -Descending | ForEach-Object {
$childItems = Get-ChildItem -LiteralPath $_.FullName -Force
if (-not $childItems) {
Remove-Item -LiteralPath $_.FullName -Force
}
}
# Deduplicate and normalize line endings
$unique = $lines | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object -Unique
Set-Content -Path $path -Value $unique -Encoding ASCII
}
function Update-KHDB {
param(
[ValidateRange(1, 64)]
[int]$MaxParallelTransfers = 5
)
Start-UpdateTranscript -BasePath $scriptRoot
try {
$settings = Read-ElysiumSettings
$settings = Read-ElysiumSettings -ScriptRoot $scriptRoot
$installPath = Get-InstallationPath $settings
if (-not (Test-Path $installPath)) { New-Item -Path $installPath -ItemType Directory -Force | Out-Null }
Ensure-Directory $installPath
$psSupportsParallel = ($PSVersionTable.PSVersion.Major -ge 7)
$effectiveParallelTransfers = if ($MaxParallelTransfers -lt 1) { 1 } else { [int]$MaxParallelTransfers }
$parallelDownloadsEnabled = $psSupportsParallel -and $effectiveParallelTransfers -gt 1
if (-not $psSupportsParallel -and $effectiveParallelTransfers -gt 1) {
Write-Verbose "Parallel transfers requested but PowerShell $($PSVersionTable.PSVersion) does not support ForEach-Object -Parallel; using serial downloads."
}
$parallelAzureDownloadHelpers = $null
$parallelAzureDownloadHelperList = @()
$parallelS3DownloadHelpers = $null
$parallelS3DownloadHelperList = @()
if ($parallelDownloadsEnabled) {
$parallelAzureDownloadHelpers = @{
'Build-BlobUri' = Get-FunctionDefinitionText 'Build-BlobUri'
'Invoke-DownloadWithRetry' = Get-FunctionDefinitionText 'Invoke-DownloadWithRetry'
'New-HttpClient' = Get-FunctionDefinitionText 'New-HttpClient'
'Get-FileSha256Lower' = Get-FunctionDefinitionText 'Get-FileSha256Lower'
}
$parallelAzureDownloadHelperList = $parallelAzureDownloadHelpers.GetEnumerator() | ForEach-Object {
[pscustomobject]@{ Name = $_.Key; Definition = $_.Value }
}
$parallelS3DownloadHelpers = @{}
@(
'Get-Bytes',
'Get-HashHex',
'HmacSha256',
'ToHex',
'GetSignatureKey',
'UriEncode',
'BuildCanonicalPath',
'BuildAuthHeaders',
'BuildS3Uri',
'Invoke-S3HttpDownloadWithRetry',
'Get-FileSha256Lower'
) | ForEach-Object {
$parallelS3DownloadHelpers[$_] = Get-FunctionDefinitionText $_
}
$parallelS3DownloadHelperList = $parallelS3DownloadHelpers.GetEnumerator() | ForEach-Object {
[pscustomobject]@{ Name = $_.Key; Definition = $_.Value }
}
}
$storageProvider = $settings['StorageProvider']
if ([string]::IsNullOrWhiteSpace($storageProvider)) { $storageProvider = 'Azure' }
$client = $null
$s3Bucket = $settings['s3BucketName']
$s3EndpointUrl = $settings['s3EndpointUrl']
$s3Region = $settings['s3Region']
$s3AK = $settings['s3AccessKeyId']
$s3SK = $settings['s3SecretAccessKey']
$s3Force = $settings['s3ForcePathStyle']
$s3UseAwsTools = $settings['s3UseAwsTools']
try { $s3Force = [System.Convert]::ToBoolean($s3Force) } catch { $s3Force = $true }
try { $s3UseAwsTools = [System.Convert]::ToBoolean($s3UseAwsTools) } catch { $s3UseAwsTools = $false }
$manifestBlobPath = $settings['KhdbManifestPath']
if ([string]::IsNullOrWhiteSpace($manifestBlobPath)) { $manifestBlobPath = 'khdb/manifest.json' }
$remoteShardPrefix = $settings['KhdbShardPrefix']
if ([string]::IsNullOrWhiteSpace($remoteShardPrefix)) { $remoteShardPrefix = 'khdb/shards' }
$localShardDirName = $settings['KhdbLocalShardDir']
if ([string]::IsNullOrWhiteSpace($localShardDirName)) { $localShardDirName = 'khdb-shards' }
$localShardRoot = Join-Path -Path $installPath -ChildPath $localShardDirName
Ensure-Directory $localShardRoot
$localManifestPath = Join-Path -Path $installPath -ChildPath 'khdb-manifest.json'
$tmpDir = New-Item -ItemType Directory -Path ([System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "elysium-khdb-" + [System.Guid]::NewGuid())) -Force
$zipPath = Join-Path -Path $tmpDir.FullName -ChildPath 'khdb.txt.zip'
$extractDir = Join-Path -Path $tmpDir.FullName -ChildPath 'extract'
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
$manifestTempPath = Join-Path -Path $tmpDir.FullName -ChildPath 'manifest.json'
$downloadTempRoot = Join-Path -Path $tmpDir.FullName -ChildPath 'shards'
Ensure-Directory $downloadTempRoot
Write-Host "Fetching manifest ($manifestBlobPath) from $storageProvider storage..."
$s3Bucket = $null
$s3EndpointUrl = $null
$s3Region = $null
$s3AK = $null
$s3SK = $null
$forcePathStyle = $true
$s3UseAwsTools = $false
$storageAccountName = $null
$containerName = $null
$sasToken = $null
if ($storageProvider -ieq 'S3') {
$s3Bucket = $settings['s3BucketName']
$s3EndpointUrl = $settings['s3EndpointUrl']
$s3Region = $settings['s3Region']
$s3AK = $settings['s3AccessKeyId']
$s3SK = $settings['s3SecretAccessKey']
$s3Force = $settings['s3ForcePathStyle']
$s3UseAwsTools = $settings['s3UseAwsTools']
if ([string]::IsNullOrWhiteSpace($s3Bucket)) { throw 's3BucketName is missing or empty.' }
if ([string]::IsNullOrWhiteSpace($s3AK) -or [string]::IsNullOrWhiteSpace($s3SK)) { throw 's3AccessKeyId / s3SecretAccessKey missing or empty.' }
if ([string]::IsNullOrWhiteSpace($s3EndpointUrl)) { throw 's3EndpointUrl is required for S3-compatible storage.' }
$forcePathStyle = Get-BooleanSetting -Value $s3Force -Default $true
try { $s3UseAwsTools = [System.Convert]::ToBoolean($s3UseAwsTools) } catch { $s3UseAwsTools = $false }
if ($parallelDownloadsEnabled -and $s3UseAwsTools) {
Write-Warning 'Parallel shard downloads require the SigV4 HTTP path; disabling AWS Tools mode for this run.'
$s3UseAwsTools = $false
}
$downloadKey = Combine-StoragePath -Prefix $null -Name $manifestBlobPath
if ($s3UseAwsTools) {
try {
$s3Client = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AK -SecretAccessKey $s3SK -ForcePathStyle:$s3Force
Write-Host "Downloading KHDB from S3-compatible storage (AWS Tools)..."
# Use AWS SDK stream method into file for progress parity
$req = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3Bucket; Key = 'khdb.txt.zip' }
$resp = $s3Client.GetObject($req)
try { $resp.WriteResponseStreamToFile($zipPath, $true) } finally { $resp.Dispose() }
$client = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AK -SecretAccessKey $s3SK -ForcePathStyle:$forcePathStyle
$req = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3Bucket; Key = $downloadKey }
$resp = $client.GetObject($req)
try { $resp.WriteResponseStreamToFile($manifestTempPath, $true) } finally { $resp.Dispose() }
} catch {
Write-Warning "AWS Tools path failed or not available. Falling back to native HTTP (SigV4). Details: $($_.Exception.Message)"
Write-Host "Downloading KHDB from S3-compatible storage..."
Invoke-S3HttpDownloadWithRetry -endpointUrl $s3EndpointUrl -bucket $s3Bucket -key 'khdb.txt.zip' -targetPath $zipPath -region $s3Region -ak $s3AK -sk $s3SK -forcePathStyle:$s3Force
Write-Warning "AWS Tools download failed for manifest: $($_.Exception.Message). Falling back to SigV4 HTTP."
Invoke-S3HttpDownloadWithRetry -EndpointUrl $s3EndpointUrl -Bucket $s3Bucket -Key $downloadKey -TargetPath $manifestTempPath -Region $s3Region -AccessKeyId $s3AK -SecretAccessKey $s3SK -ForcePathStyle:$forcePathStyle -Activity 'Downloading manifest'
}
} else {
Write-Host "Downloading KHDB from S3-compatible storage..."
Invoke-S3HttpDownloadWithRetry -endpointUrl $s3EndpointUrl -bucket $s3Bucket -key 'khdb.txt.zip' -targetPath $zipPath -region $s3Region -ak $s3AK -sk $s3SK -forcePathStyle:$s3Force
Invoke-S3HttpDownloadWithRetry -EndpointUrl $s3EndpointUrl -Bucket $s3Bucket -Key $downloadKey -TargetPath $manifestTempPath -Region $s3Region -AccessKeyId $s3AK -SecretAccessKey $s3SK -ForcePathStyle:$forcePathStyle -Activity 'Downloading manifest'
}
} else {
$storageAccountName = $settings['storageAccountName']
$containerName = $settings['containerName']
$sasToken = $settings['sasToken']
$uri = Build-BlobUri -account $storageAccountName -container $containerName -sas $sasToken
$client = New-HttpClient
Write-Host "Downloading KHDB from Azure Blob Storage..."
Invoke-DownloadWithRetry -client $client -uri $uri -targetPath $zipPath
try {
$uri = Build-BlobUri -Account $storageAccountName -Container $containerName -Sas $sasToken -BlobName $manifestBlobPath
Invoke-DownloadWithRetry -Client $client -Uri $uri -TargetPath $manifestTempPath -Activity 'Downloading manifest'
} finally {
if ($client) { $client.Dispose() }
}
}
Write-Host "Download completed. Extracting archive..."
Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
$manifest = Load-Manifest -Path $manifestTempPath
Validate-Manifest -Manifest $manifest
if ($manifest.shardPrefix) {
$remoteShardPrefix = [string]$manifest.shardPrefix
Write-Verbose "Using shard prefix from manifest: $remoteShardPrefix"
}
$extractedKHDB = Get-ChildItem -Path $extractDir -Recurse -Filter 'khdb.txt' | Select-Object -First 1
if (-not $extractedKHDB) { throw 'Extracted archive does not contain khdb.txt.' }
$remoteShardPrefix = $remoteShardPrefix.Replace('\', '/')
# Validate content
Validate-KHDBFile -path $extractedKHDB.FullName
Write-Host ("Manifest downloaded. Found {0} shard(s)." -f $manifest.shards.Count)
if ($manifest.version) {
Write-Host ("Remote version: {0}" -f $manifest.version)
}
# Compute target path and backup
$targetKHDB = Join-Path -Path $installPath -ChildPath 'khdb.txt'
if (Test-Path $targetKHDB) {
$localManifest = $null
if (Test-Path -LiteralPath $localManifestPath) {
try {
$localManifest = Load-Manifest -Path $localManifestPath
} catch {
Write-Warning ("Failed to parse existing manifest. Full refresh will occur: {0}" -f $_.Exception.Message)
}
}
$localManifestMap = @{}
if ($localManifest -and $localManifest.shards) {
foreach ($entry in $localManifest.shards) {
if ($entry.name) { $localManifestMap[$entry.name] = $entry }
}
}
$downloadQueue = [System.Collections.ArrayList]::new()
$remoteNameSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($entry in $manifest.shards) {
$name = [string]$entry.name
[void]$remoteNameSet.Add($name)
$expectedHash = ([string]$entry.sha256).ToLowerInvariant()
$expectedSize = 0L
if (-not [long]::TryParse([string]$entry.size, [ref]$expectedSize)) {
throw "Cannot parse size for shard '$name'."
}
$localPath = Join-Path -Path $localShardRoot -ChildPath $name
$needsDownload = $true
if (Test-Path -LiteralPath $localPath) {
$localInfo = Get-Item -LiteralPath $localPath
if ($localInfo.Length -eq $expectedSize) {
$localManifestEntry = $null
if ($localManifestMap.ContainsKey($name)) {
$localManifestEntry = $localManifestMap[$name]
}
if ($localManifestEntry -and ([string]$localManifestEntry.sha256).ToLowerInvariant() -eq $expectedHash) {
$needsDownload = $false
} else {
$localHash = Get-FileSha256Lower -Path $localPath
if ($localHash -eq $expectedHash) { $needsDownload = $false }
}
}
}
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)
} else {
Write-Host 'All shards already up to date; verifying manifest and combined file.'
}
$storageClient = $null
$storageHttpClient = $null
$isS3 = ($storageProvider -ieq 'S3')
try {
if ($isS3) {
$s3Bucket = $settings['s3BucketName']
$s3EndpointUrl = $settings['s3EndpointUrl']
$s3Region = $settings['s3Region']
$s3AK = $settings['s3AccessKeyId']
$s3SK = $settings['s3SecretAccessKey']
$s3Force = $settings['s3ForcePathStyle']
$forcePathStyle = Get-BooleanSetting -Value $s3Force -Default $true
$useAwsTools = $settings['s3UseAwsTools']
try { $useAwsTools = [System.Convert]::ToBoolean($useAwsTools) } catch { $useAwsTools = $false }
if ($downloadQueue.Count -gt 0 -and -not $parallelDownloadsEnabled) {
if ($useAwsTools) {
$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
}
}
} else {
if ($downloadQueue.Count -gt 0 -and -not $parallelDownloadsEnabled) {
$storageHttpClient = New-HttpClient
}
}
if ($parallelDownloadsEnabled -and $downloadQueue.Count -gt 0) {
Write-Host ("Downloading shards with up to {0} concurrent transfer(s)..." -f $effectiveParallelTransfers)
$remotePrefixForParallel = if ([string]::IsNullOrWhiteSpace($remoteShardPrefix)) { $null } else { $remoteShardPrefix.Replace('\', '/').Trim('/') }
$parallelDownloadHelpers = if ($isS3) { $parallelS3DownloadHelperList } else { $parallelAzureDownloadHelperList }
$downloadQueue.ToArray() | ForEach-Object -Parallel {
$entry = $PSItem
try {
if ($null -eq $entry) { return }
foreach ($helper in $using:parallelDownloadHelpers) {
if (-not (Get-Command $helper.Name -ErrorAction SilentlyContinue)) {
Invoke-Expression $helper.Definition
}
}
$name = [string]$entry.Name
if ([string]::IsNullOrWhiteSpace($name)) {
throw "Parallel shard entry missing name: $(ConvertTo-Json $entry -Compress)"
}
$expectedHash = ([string]$entry.Sha256).ToLowerInvariant()
$expectedSize = [long]$entry.Size
$remoteKey = $name.Replace('\', '/').TrimStart('/')
if (-not [string]::IsNullOrWhiteSpace($using:remotePrefixForParallel)) {
$remoteKey = $using:remotePrefixForParallel + '/' + $remoteKey
}
$stagingPath = Join-Path -Path $using:downloadTempRoot -ChildPath $name
$stagingParent = Split-Path -Path $stagingPath -Parent
if ($stagingParent -and -not (Test-Path -LiteralPath $stagingParent)) {
[System.IO.Directory]::CreateDirectory($stagingParent) | Out-Null
}
$activity = ("Downloading shard: {0}" -f $name)
if ($using:isS3) {
Invoke-S3HttpDownloadWithRetry -EndpointUrl $using:s3EndpointUrl -Bucket $using:s3Bucket -Key $remoteKey -TargetPath $stagingPath -Region $using:s3Region -AccessKeyId $using:s3AK -SecretAccessKey $using:s3SK -ForcePathStyle:$using:forcePathStyle -Activity $activity
} else {
$client = $null
try {
$client = New-HttpClient
$blobUri = Build-BlobUri -Account $using:storageAccountName -Container $using:containerName -Sas $using:sasToken -BlobName $remoteKey
Invoke-DownloadWithRetry -Client $client -Uri $blobUri -TargetPath $stagingPath -Activity $activity
} finally {
if ($client) { $client.Dispose() }
}
}
$downloadInfo = Get-Item -LiteralPath $stagingPath
if ($downloadInfo.Length -ne $expectedSize) {
throw "Shard '$name' size mismatch. Expected $expectedSize bytes, got $($downloadInfo.Length)."
}
$actualHash = Get-FileSha256Lower -Path $stagingPath
if ($actualHash -ne $expectedHash) {
throw "Shard '$name' checksum mismatch. Expected $expectedHash, got $actualHash."
}
$finalPath = Join-Path -Path $using:localShardRoot -ChildPath $name
$parentDir = Split-Path -Path $finalPath -Parent
if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) {
[System.IO.Directory]::CreateDirectory($parentDir) | Out-Null
}
Move-Item -LiteralPath $stagingPath -Destination $finalPath -Force
Write-Host ("Shard '{0}' updated." -f $name)
} catch {
throw ("Shard '{0}': {1}" -f $entry.name, $_.Exception.Message)
}
} -ThrottleLimit $effectiveParallelTransfers
} else {
$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)"
}
$expectedHash = ([string]$entry.Sha256).ToLowerInvariant()
$expectedSize = [long]$entry.Size
$activity = "Downloading shard $downloadIndex/$($downloadQueue.Count): $name"
$remoteKey = Combine-StoragePath -Prefix $remoteShardPrefix -Name $name
$stagingPath = Join-Path -Path $downloadTempRoot -ChildPath $name
Ensure-Directory (Split-Path -Path $stagingPath -Parent)
if ($isS3) {
if ($storageClient) {
try {
$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 {
Write-Warning "AWS Tools download failed for shard '$name': $($_.Exception.Message). Falling back to SigV4 HTTP."
Invoke-S3HttpDownloadWithRetry -EndpointUrl $storageHttpClient.Endpoint -Bucket $storageHttpClient.Bucket -Key $remoteKey -TargetPath $stagingPath -Region $storageHttpClient.Region -AccessKeyId $storageHttpClient.AccessKey -SecretAccessKey $storageHttpClient.SecretKey -ForcePathStyle:$storageHttpClient.ForcePath -Activity $activity
}
} else {
Invoke-S3HttpDownloadWithRetry -EndpointUrl $storageHttpClient.Endpoint -Bucket $storageHttpClient.Bucket -Key $remoteKey -TargetPath $stagingPath -Region $storageHttpClient.Region -AccessKeyId $storageHttpClient.AccessKey -SecretAccessKey $storageHttpClient.SecretKey -ForcePathStyle:$storageHttpClient.ForcePath -Activity $activity
}
} else {
$blobUri = Build-BlobUri -Account $storageAccountName -Container $containerName -Sas $sasToken -BlobName $remoteKey
Invoke-DownloadWithRetry -Client $storageHttpClient -Uri $blobUri -TargetPath $stagingPath -Activity $activity
}
$downloadInfo = Get-Item -LiteralPath $stagingPath
if ($downloadInfo.Length -ne $expectedSize) {
throw "Shard '$name' size mismatch. Expected $expectedSize bytes, got $($downloadInfo.Length)."
}
$actualHash = Get-FileSha256Lower -Path $stagingPath
if ($actualHash -ne $expectedHash) {
throw "Shard '$name' checksum mismatch. Expected $expectedHash, got $actualHash."
}
$finalPath = Join-Path -Path $localShardRoot -ChildPath $name
Ensure-Directory (Split-Path -Path $finalPath -Parent)
Move-Item -LiteralPath $stagingPath -Destination $finalPath -Force
Write-Host ("Shard '{0}' updated." -f $name)
}
}
} finally {
if ($storageClient) { $storageClient.Dispose() }
if ($storageHttpClient -is [System.Net.Http.HttpClient]) { $storageHttpClient.Dispose() }
}
$existingShards = @()
if (Test-Path -LiteralPath $localShardRoot) {
$existingShards = Get-ChildItem -LiteralPath $localShardRoot -File -Recurse
}
$removed = 0
foreach ($file in $existingShards) {
$relative = Get-RelativePath -BasePath $localShardRoot -FullPath $file.FullName
if (-not $remoteNameSet.Contains($relative)) {
Remove-Item -LiteralPath $file.FullName -Force
$removed++
}
}
if ($removed -gt 0) {
Write-Host ("Removed {0} stale shard(s)." -f $removed)
Remove-EmptyDirectories -Root $localShardRoot
}
Copy-Item -LiteralPath $manifestTempPath -Destination $localManifestPath -Force
Write-Host ("Manifest saved locally to {0}" -f $localManifestPath)
$khdbName = if ([string]::IsNullOrWhiteSpace($settings['WeakPasswordsDatabase'])) { 'khdb.txt' } else { $settings['WeakPasswordsDatabase'] }
$combinedTarget = Join-Path -Path $installPath -ChildPath $khdbName
$combinedTemp = Join-Path -Path $tmpDir.FullName -ChildPath 'khdb-combined.txt'
Write-Host "Rebuilding combined KHDB file..."
Merge-ShardsToFile -Manifest $manifest -ShardsRoot $localShardRoot -TargetPath $combinedTemp
Validate-KHDBFile -Path $combinedTemp
if (Test-Path -LiteralPath $combinedTarget) {
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
$backupPath = Join-Path -Path $installPath -ChildPath ("khdb.txt.bak-$ts")
Copy-Item -Path $targetKHDB -Destination $backupPath -Force
Write-Host "Existing KHDB backed up to $backupPath"
$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)" }
}
}
# Atomic-ish replace: move validated file into place
Move-Item -Path $extractedKHDB.FullName -Destination $targetKHDB -Force
Write-Host "KHDB updated at $targetKHDB"
Move-Item -LiteralPath $combinedTemp -Destination $combinedTarget -Force
Write-Host ("KHDB merged file refreshed at {0}" -f $combinedTarget)
Write-Host "KHDB update completed successfully."
} catch {
Write-Error ("KHDB update failed: {0}" -f $_.Exception.Message)
@@ -330,6 +799,5 @@ function Update-KHDB {
}
}
# Execute the update function
Update-KHDB
Write-Host "Script execution completed."
+167
View File
@@ -0,0 +1,167 @@
##################################################
## ____ ___ ____ _____ _ _ _____ _____ ##
## / ___/ _ \| _ \| ____| | \ | | ____|_ _| ##
## | | | | | | |_) | _| | \| | _| | | ##
## | |__| |_| | _ <| |___ _| |\ | |___ | | ##
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
##################################################
## Project: Elysium ##
## File: Update-LithnetStore.ps1 ##
## Version: 2.4.6 ##
## Support: support@cqre.net ##
##################################################
<#
.SYNOPSIS
Populates a Lithnet Password Protection store with compromised passwords and banned words.
.DESCRIPTION
Reads configuration from ElysiumSettings.txt (or a provided settings file), opens the Lithnet
Password Protection store, optionally synchronizes with Have I Been Pwned, imports local NTLM
hash lists, plaintext password lists, and banned-word files.
#>
[CmdletBinding()]
param(
[string]$SettingsPath,
[string[]]$HashFiles,
[string[]]$PlaintextFiles,
[string[]]$BannedWordFiles,
[switch]$SkipHibpSync
)
$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
Restart-WithPwshIfAvailable -BoundParameters $PSBoundParameters -UnboundArguments $MyInvocation.UnboundArguments
function Read-KeyValueSettings {
param([string]$Path)
$result = @{}
if (-not (Test-Path -LiteralPath $Path)) { throw "Settings file not found at $Path" }
Get-Content -LiteralPath $Path | ForEach-Object {
$line = $_
if (-not $line) { return }
$trimmed = $line.Trim()
if (-not $trimmed) { return }
if ($trimmed.StartsWith('#')) { return }
$kv = $line -split '=', 2
if ($kv.Count -ne 2) { return }
$key = $kv[0].Trim()
$value = $kv[1].Trim()
if (-not $key) { return }
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-BooleanSetting {
param(
[string]$Value,
[bool]$Default = $false
)
if ([string]::IsNullOrWhiteSpace($Value)) { return $Default }
try { return [System.Convert]::ToBoolean($Value) } catch { return $Default }
}
function Get-ListFromSetting {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return @() }
return ($Value -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
function Resolve-ExistingPath {
param([string]$PathValue, [string]$Description)
if ([string]::IsNullOrWhiteSpace($PathValue)) { throw "$Description path was not provided." }
if (-not (Test-Path -LiteralPath $PathValue)) {
throw "$Description not found at '$PathValue'."
}
return (Resolve-Path -LiteralPath $PathValue).Path
}
if (-not $SettingsPath) {
$SettingsPath = Join-Path -Path $PSScriptRoot -ChildPath 'ElysiumSettings.txt'
}
$settings = Read-KeyValueSettings -Path $SettingsPath
$storePathSetting = $settings['LithnetStorePath']
$storePath = Resolve-ExistingPath -PathValue $storePathSetting -Description 'Lithnet store'
$settingsHashSources = Get-ListFromSetting -Value $settings['LithnetHashSources']
$settingsPlainSources = Get-ListFromSetting -Value $settings['LithnetPlaintextSources']
$settingsBannedSources = Get-ListFromSetting -Value $settings['LithnetBannedWordSources']
$hashSourcePaths = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($path in @($HashFiles) + $settingsHashSources) {
if ([string]::IsNullOrWhiteSpace($path)) { continue }
$resolved = Resolve-ExistingPath -PathValue $path -Description 'Hash list'
[void]$hashSourcePaths.Add($resolved)
}
if ($hashSourcePaths.Count -eq 0) {
$defaultKhdb = Join-Path -Path $PSScriptRoot -ChildPath 'khdb.txt'
if (Test-Path -LiteralPath $defaultKhdb) {
[void]$hashSourcePaths.Add((Resolve-Path -LiteralPath $defaultKhdb).Path)
} else {
throw 'No hash files were provided via parameters or LithnetHashSources, and khdb.txt was not found.'
}
}
$plaintextSourcePaths = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($path in @($PlaintextFiles) + $settingsPlainSources) {
if ([string]::IsNullOrWhiteSpace($path)) { continue }
$resolved = Resolve-ExistingPath -PathValue $path -Description 'Plaintext password list'
[void]$plaintextSourcePaths.Add($resolved)
}
$bannedWordSourcePaths = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($path in @($BannedWordFiles) + $settingsBannedSources) {
if ([string]::IsNullOrWhiteSpace($path)) { continue }
$resolved = Resolve-ExistingPath -PathValue $path -Description 'Banned word list'
[void]$bannedWordSourcePaths.Add($resolved)
}
$syncHibp = Get-BooleanSetting -Value $settings['LithnetSyncHibp'] -Default:$false
if ($SkipHibpSync) { $syncHibp = $false }
Write-Host "Importing LithnetPasswordProtection module..."
try {
Import-Module -Name LithnetPasswordProtection -ErrorAction Stop | Out-Null
} catch {
throw "LithnetPasswordProtection module not found: $($_.Exception.Message). Install it from https://github.com/lithnet/password-protection."
}
Write-Host ("Opening Lithnet store at '{0}'..." -f $storePath)
Open-Store -Path $storePath
$storeOpened = $true
try {
if ($syncHibp) {
Write-Host 'Synchronizing compromised hashes from Have I Been Pwned (this can take a while)...'
Sync-HashesFromHibp
}
foreach ($hashFile in ($hashSourcePaths.ToArray() | Sort-Object)) {
Write-Host ("Importing NTLM hash list '{0}'..." -f $hashFile)
Import-CompromisedPasswordHashes -Filename $hashFile
}
foreach ($plainFile in ($plaintextSourcePaths.ToArray() | Sort-Object)) {
Write-Host ("Importing plaintext password list '{0}'..." -f $plainFile)
Import-CompromisedPasswords -Filename $plainFile
}
foreach ($bannedFile in ($bannedWordSourcePaths.ToArray() | Sort-Object)) {
Write-Host ("Importing banned word list '{0}'..." -f $bannedFile)
Import-BannedWords -Filename $bannedFile
}
Write-Host 'Lithnet store update completed successfully.'
} finally {
if ($storeOpened) {
try { Close-Store } catch {}
}
}