Skip to content

Commit 986102d

Browse files
authored
Merge pull request #61 from jakehildreth:feat/connection-context-resolution
feat(connection-context): auto-detect forest/credential with interactive approval prompt
2 parents 83165f6 + 6c626a1 commit 986102d

13 files changed

Lines changed: 625 additions & 22 deletions

Locksmith2.psd1

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@
88
Description='An AD CS toolkit for AD Admins, Defensive Security Professionals, and Filthy Red Teamers'
99
FunctionsToExport=@('*')
1010
GUID='e32f7d0d-2b10-4db2-b776-a193958e3d69'
11-
ModuleVersion='2026.5.100712'
11+
ModuleVersion='2026.5.101055'
1212
PowerShellVersion='5.1'
1313
PrivateData=@{
1414
PSData=@{
1515
ExternalModuleDependencies=@('Microsoft.PowerShell.Utility', 'Microsoft.PowerShell.Archive', 'Microsoft.PowerShell.Management', 'Microsoft.PowerShell.Security', 'PowerShellGet', 'CimCmdlets')
16-
Prerelease='pre'
1716
ProjectUri='https://github.com/jakehildreth/Locksmith2'
1817
RequireLicenseAcceptance=$false
1918
Tags=@('Locksmith', 'Locksmith2', 'ActiveDirectory', 'ADCS', 'CA', 'Certificate', 'CertificateAuthority', 'CertificateServices', 'PKI', 'X509', 'Windows')

Private/Get/Get-RootDSE.ps1

-2.49 KB
Binary file not shown.

Private/Initialize/Initialize-AdcsObjectStore.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ function Initialize-AdcsObjectStore {
4646
}
4747

4848
process {
49-
# Require Credential and RootDSE
50-
if (-not $script:Credential) {
49+
# Require Credential unless Resolve-LS2ConnectionContext determined none is needed (e.g. DomainUser path)
50+
if (-not $script:Credential -and -not $script:CredentialResolved) {
5151
Write-Warning "Credential not set. Cannot initialize AdcsObjectStore."
5252
return
5353
}

Private/Initialize/Initialize-DomainStore.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ function Initialize-DomainStore {
4747
}
4848

4949
process {
50-
# Require Credential and RootDSE
51-
if (-not $script:Credential) {
50+
# Require Credential unless Resolve-LS2ConnectionContext determined none is needed (e.g. DomainUser path)
51+
if (-not $script:Credential -and -not $script:CredentialResolved) {
5252
Write-Warning "Credential not set. Cannot initialize DomainStore."
5353
return
5454
}

Private/Initialize/Initialize-LS2Scan.ps1

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,11 @@ function Initialize-LS2Scan {
111111
Set-LS2Forest -Forest $Forest
112112
}
113113

114-
if ($PSBoundParameters.ContainsKey('Credential') -or -not $script:Credential) {
115-
Set-LS2Credential -Credential $Credential
114+
# Skip credential prompt if Resolve-LS2ConnectionContext already determined none is needed
115+
if (-not $script:CredentialResolved) {
116+
if ($PSBoundParameters.ContainsKey('Credential') -or -not $script:Credential) {
117+
Set-LS2Credential -Credential $Credential
118+
}
116119
}
117120

118121
if (-not $script:RootDSE) {

Private/New/New-AuthenticatedDirectoryEntry.ps1

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,13 @@ function New-AuthenticatedDirectoryEntry {
3838
$Path
3939
)
4040

41-
return New-Object System.DirectoryServices.DirectoryEntry(
42-
$Path,
43-
$script:Credential.UserName,
44-
$script:Credential.GetNetworkCredential().Password
45-
)
41+
if ($script:Credential) {
42+
return New-Object System.DirectoryServices.DirectoryEntry(
43+
$Path,
44+
$script:Credential.UserName,
45+
$script:Credential.GetNetworkCredential().Password
46+
)
47+
} else {
48+
return New-Object System.DirectoryServices.DirectoryEntry($Path)
49+
}
4650
}

Private/Test/Test-IsDomainUser.ps1

-18 Bytes
Binary file not shown.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
function Test-IsInteractiveSession {
2+
<#
3+
.SYNOPSIS
4+
Tests if the current PowerShell session is interactive (a human is at the keyboard).
5+
6+
.DESCRIPTION
7+
Determines whether the current session is running interactively by checking two conditions:
8+
- [Environment]::UserInteractive: false when running as a service, scheduled task, or
9+
non-interactive process (e.g. SYSTEM via Invoke-CommandAs)
10+
- [Console]::IsInputRedirected: true when stdin is piped or redirected (automation)
11+
12+
Both conditions must indicate an interactive context for this function to return $true.
13+
14+
.INPUTS
15+
None. This function does not accept pipeline input.
16+
17+
.OUTPUTS
18+
System.Boolean
19+
Returns $true if the session is interactive.
20+
Returns $false if running non-interactively (service, scheduled task, piped input, CI).
21+
22+
.EXAMPLE
23+
Test-IsInteractiveSession
24+
Returns $true when a human is at the keyboard.
25+
26+
.EXAMPLE
27+
if (Test-IsInteractiveSession) {
28+
$cred = Get-Credential
29+
} else {
30+
throw 'No credential supplied and session is non-interactive.'
31+
}
32+
33+
.NOTES
34+
Used by Resolve-LS2ConnectionContext and Get-RootDSE to gate interactive prompts.
35+
#>
36+
[CmdletBinding()]
37+
[OutputType([bool])]
38+
param ()
39+
40+
#requires -Version 5.1
41+
42+
$isUserInteractive = [Environment]::UserInteractive
43+
$isInputRedirected = [Console]::IsInputRedirected
44+
45+
Write-Verbose "UserInteractive: $isUserInteractive, IsInputRedirected: $isInputRedirected"
46+
47+
return $isUserInteractive -and -not $isInputRedirected
48+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
function Resolve-LS2ConnectionContext {
2+
<#
3+
.SYNOPSIS
4+
Detects the appropriate forest name and credential for a Locksmith2 scan.
5+
6+
.DESCRIPTION
7+
Applies a prioritized detection strategy to determine the correct AD forest
8+
and credential to use:
9+
10+
1. Both -Forest and -Credential supplied at CLI -> Explicit
11+
2. -Credential only (no -Forest) -> ExplicitCredential (forest derived from UserName)
12+
3. -Forest only; current user is domain user -> DomainUser (no credential)
13+
4. Neither; current user is domain user -> DomainUser (forest from GetCurrentDomain)
14+
5. Non-domain user, domain-joined machine -> DomainComputer (machine account auth, no credential)
15+
6. Non-domain user, non-domain machine, interactive -> PromptedAll (prompt for both)
16+
7. Non-domain user, non-domain machine, non-interactive -> terminating error
17+
18+
When running interactively, failed RootDSE binds trigger up to 3 retry prompts.
19+
20+
.PARAMETER Forest
21+
Optional. DNS name of the target AD forest. If omitted, auto-detection is used.
22+
23+
.PARAMETER Credential
24+
Optional. PSCredential for the scan. If omitted, auto-detection is used.
25+
26+
.OUTPUTS
27+
System.Collections.Hashtable with keys: Forest, Credential, Method
28+
29+
.EXAMPLE
30+
$ctx = Resolve-LS2ConnectionContext
31+
Initialize-LS2Scan -Forest $ctx.Forest -Credential $ctx.Credential
32+
33+
.NOTES
34+
Method values: Explicit | ExplicitCredential | DomainUser | DomainComputer | PromptedAll
35+
#>
36+
[CmdletBinding()]
37+
[OutputType([hashtable])]
38+
param (
39+
[Parameter()]
40+
[string]$Forest,
41+
42+
[Parameter()]
43+
[System.Management.Automation.PSCredential]$Credential
44+
)
45+
46+
# -------------------------------------------------------------------------
47+
# Short-circuit: both explicitly supplied
48+
if ($Forest -and $Credential) {
49+
return @{
50+
Forest = $Forest
51+
Credential = $Credential
52+
Method = 'Explicit'
53+
}
54+
}
55+
56+
# -------------------------------------------------------------------------
57+
# Credential-only: derive forest from UserName (DOMAIN\user or user@domain.com)
58+
if (-not $Forest -and $Credential) {
59+
$derivedForest = if ($Credential.UserName -match '^([^\\]+)\\') {
60+
$Matches[1]
61+
} elseif ($Credential.UserName -match '@(.+)$') {
62+
$Matches[1]
63+
} else {
64+
$Credential.UserName
65+
}
66+
return @{
67+
Forest = $derivedForest
68+
Credential = $Credential
69+
Method = 'ExplicitCredential'
70+
}
71+
}
72+
73+
# -------------------------------------------------------------------------
74+
# Forest-only or neither: run detection
75+
$maxAttempts = 3
76+
$attempt = 0
77+
$resolvedForest = $Forest # may be $null if neither was supplied
78+
79+
do {
80+
$attempt++
81+
82+
# Step 1: domain user path
83+
if (Test-IsDomainUser) {
84+
if (-not $resolvedForest) {
85+
try {
86+
$resolvedForest = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain().Name
87+
} catch {
88+
$PSCmdlet.WriteError(
89+
[System.Management.Automation.ErrorRecord]::new(
90+
[System.Exception]::new('Unable to determine current domain. Supply -Forest explicitly.'),
91+
'DomainDiscoveryFailed',
92+
[System.Management.Automation.ErrorCategory]::ObjectNotFound,
93+
$null
94+
)
95+
)
96+
return
97+
}
98+
}
99+
$script:CredentialResolved = $true
100+
return @{
101+
Forest = $resolvedForest
102+
Credential = $null
103+
Method = 'DomainUser'
104+
}
105+
}
106+
107+
# Step 2: domain-joined machine — authenticate via computer account
108+
if (Test-IsDomainComputer) {
109+
$computerInfo = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue
110+
$machineDomain = $computerInfo.Domain
111+
112+
$script:CredentialResolved = $true
113+
return @{
114+
Forest = if ($resolvedForest) { $resolvedForest } else { $machineDomain }
115+
Credential = $null
116+
Method = 'DomainComputer'
117+
}
118+
}
119+
120+
# Step 3: non-domain machine — must prompt for both
121+
if (Test-IsInteractiveSession) {
122+
$promptedForest = Read-Host -Prompt 'Enter the target AD forest DNS name'
123+
Write-Host ''
124+
Write-Host 'Windows PowerShell credential request'
125+
Write-Host "Enter credentials for forest '$promptedForest'"
126+
$promptedUser = Read-Host 'User (DOMAIN\username or user@domain.com)'
127+
$promptedPass = Read-Host "Password for user $promptedUser" -AsSecureString
128+
$promptedCred = [System.Management.Automation.PSCredential]::new($promptedUser, $promptedPass)
129+
130+
if ($promptedForest -and $promptedCred) {
131+
$ctx = @{
132+
Forest = $promptedForest
133+
Credential = $promptedCred
134+
Method = 'PromptedAll'
135+
}
136+
# Validate via RootDSE bind
137+
$testRootDSE = Get-RootDSE -Forest $ctx.Forest -Credential $ctx.Credential -ErrorAction SilentlyContinue
138+
if ($testRootDSE) {
139+
return $ctx
140+
}
141+
Write-Warning "RootDSE bind failed for '$promptedForest'. Attempt $attempt of $maxAttempts."
142+
continue
143+
}
144+
}
145+
146+
# Non-interactive, non-domain — nothing we can do
147+
$PSCmdlet.ThrowTerminatingError(
148+
[System.Management.Automation.ErrorRecord]::new(
149+
[System.Exception]::new('Cannot resolve connection context in non-interactive session on a non-domain machine. Supply -Forest and -Credential explicitly.'),
150+
'NonInteractiveResolutionFailed',
151+
[System.Management.Automation.ErrorCategory]::AuthenticationError,
152+
$null
153+
)
154+
)
155+
return
156+
157+
} while ($attempt -lt $maxAttempts)
158+
159+
# Exhausted all attempts
160+
$PSCmdlet.ThrowTerminatingError(
161+
[System.Management.Automation.ErrorRecord]::new(
162+
[System.Exception]::new("Failed to establish a valid connection context after $maxAttempts attempts. Supply -Forest and -Credential explicitly."),
163+
'ConnectionContextResolutionExhausted',
164+
[System.Management.Automation.ErrorCategory]::AuthenticationError,
165+
$null
166+
)
167+
)
168+
}

Public/Invoke-Locksmith2.ps1

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,20 +145,49 @@ function Invoke-Locksmith2 {
145145
}
146146

147147
Write-Verbose "Starting Locksmith2 AD CS security audit..."
148-
149-
# Initialize and optionally rescan
150-
# Only pass Forest/Credential if explicitly provided by user
151-
$initParams = @{}
152-
if ($PSBoundParameters.ContainsKey('Forest')) {
153-
$initParams['Forest'] = $Forest
148+
149+
# Reset credential-resolved flag so each run re-evaluates context
150+
$script:CredentialResolved = $false
151+
152+
# Resolve connection context - auto-detects forest and credential if not supplied at CLI
153+
$ctxParams = @{}
154+
if ($PSBoundParameters.ContainsKey('Forest')) { $ctxParams['Forest'] = $Forest }
155+
if ($PSBoundParameters.ContainsKey('Credential')) { $ctxParams['Credential'] = $Credential }
156+
$ctx = Resolve-LS2ConnectionContext @ctxParams
157+
158+
if (-not $ctx) {
159+
Write-Error 'Failed to resolve connection context. Supply -Forest and -Credential explicitly.'
160+
return
154161
}
155-
if ($PSBoundParameters.ContainsKey('Credential')) {
156-
$initParams['Credential'] = $Credential
162+
163+
Write-Verbose "Connection context resolved: Forest=$($ctx.Forest), Method=$($ctx.Method)"
164+
165+
if (Test-IsInteractiveSession) {
166+
$rawUser = if ($ctx.Credential) { $ctx.Credential.UserName } else { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name }
167+
$userDisplay = if ($rawUser -match '^([^\\]+)\\(.+)$') { "$($Matches[1].ToUpper())\$($Matches[2])" } else { $rawUser }
168+
Write-Host ''
169+
Write-Host 'Connection Context' -ForegroundColor Cyan
170+
Write-Host " Forest : $($ctx.Forest)"
171+
Write-Host " User : $userDisplay"
172+
Write-Host " Computer : $($env:USERDOMAIN.ToUpper())\$($env:COMPUTERNAME.ToUpper())"
173+
Write-Host " Method : $($ctx.Method)"
174+
Write-Host ''
175+
$confirm = Read-Choice -Question 'Proceed with scan?' -Options @('y', 'n') -Default 'y'
176+
if ($confirm -ne 'y') {
177+
Write-Host 'Scan cancelled.' -ForegroundColor Yellow
178+
return
179+
}
180+
Write-Host ''
181+
}
182+
183+
$initParams = @{
184+
Forest = $ctx.Forest
185+
Credential = $ctx.Credential
157186
}
158187
if ($Rescan) {
159188
$initParams['Rescan'] = $true
160189
}
161-
190+
162191
$initResult = Initialize-LS2Scan @initParams
163192

164193
if (-not $initResult) {

0 commit comments

Comments
 (0)