Private/Get-LocalPasswordPolicy.ps1
|
<#
.SYNOPSIS Locale-invariant local password/lockout policy helper (private). .DESCRIPTION Get-LocalPasswordPolicy queries the local SAM account policy via NetUserModalsGet (netapi32, levels 0 and 3). When elevated, it merges PasswordComplexity/ClearTextPassword (and serves as full fallback) from a secedit /export INF whose [System Access] keys are English on all locales. NEVER calls 'net accounts' (localized output). ConvertTo-PolicyFromModals holds all pure unit/sentinel conversion logic so it can be tested off-Windows (no P/Invoke, no OS calls). .NOTES Struct layouts verified from lmaccess.h (USER_MODALS_INFO_0/3). ALL NetApi time fields are SECONDS: USER_MODALS_INFO_0 max/min password age and force-logoff, and USER_MODALS_INFO_3 lockout duration and observation window (Microsoft Learn: usrmod3_lockout_duration "Specifies, in seconds..."; usrmod3_lockout_observation_window "...in seconds"). TIMEQ_FOREVER ([uint32]::MaxValue) maps to -1 ("never" / "until admin unlocks"). Sentinel checks MUST compare against [uint32]::MaxValue, never 0xFFFFFFFF: PowerShell parses the literal 0xFFFFFFFF as [int] -1, so '[uint32]4294967295 -eq 0xFFFFFFFF' is $false and the branch never fires. #> function ConvertTo-PolicyFromModals { [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter()] $Modals0, [Parameter()] $Modals3 ) $converted = [PSCustomObject]@{ MinPasswordLength = $null MaxPasswordAgeDays = $null MinPasswordAgeDays = $null PasswordHistoryLength = $null ForceLogoffHours = $null LockoutThreshold = $null LockoutDurationMinutes = $null LockoutObservationWindowMinutes = $null } if ($null -ne $Modals0) { $converted.MinPasswordLength = [int]$Modals0.usrmod0_min_passwd_len # USER_MODALS_INFO_0 ages are seconds; TIMEQ_FOREVER = [uint32]::MaxValue. if ($Modals0.usrmod0_max_passwd_age -eq [uint32]::MaxValue) { $converted.MaxPasswordAgeDays = -1 } else { $converted.MaxPasswordAgeDays = [int][math]::Round($Modals0.usrmod0_max_passwd_age / 86400) } if ($Modals0.usrmod0_min_passwd_age -eq [uint32]::MaxValue) { $converted.MinPasswordAgeDays = -1 } else { $converted.MinPasswordAgeDays = [int][math]::Round($Modals0.usrmod0_min_passwd_age / 86400) } if ($Modals0.usrmod0_force_logoff -eq [uint32]::MaxValue) { $converted.ForceLogoffHours = -1 } else { $converted.ForceLogoffHours = [int][math]::Round($Modals0.usrmod0_force_logoff / 3600) } $converted.PasswordHistoryLength = [int]$Modals0.usrmod0_password_hist_len } if ($null -ne $Modals3) { # USER_MODALS_INFO_3 lockout duration and observation window are SECONDS # (Microsoft Learn), so divide by 60 to report minutes. TIMEQ_FOREVER # ([uint32]::MaxValue) duration = "until admin unlocks" -> -1, checked # BEFORE the seconds-to-minutes conversion. # Reported even when threshold is 0 (lockout disabled). if ($Modals3.usrmod3_lockout_duration -eq [uint32]::MaxValue) { $converted.LockoutDurationMinutes = -1 } else { $converted.LockoutDurationMinutes = [int][math]::Round($Modals3.usrmod3_lockout_duration / 60) } if ($Modals3.usrmod3_lockout_observation_window -eq [uint32]::MaxValue) { $converted.LockoutObservationWindowMinutes = -1 } else { $converted.LockoutObservationWindowMinutes = [int][math]::Round($Modals3.usrmod3_lockout_observation_window / 60) } $converted.LockoutThreshold = [int]$Modals3.usrmod3_lockout_threshold } return $converted } function Get-LocalPasswordPolicy { [CmdletBinding()] [OutputType([PSCustomObject])] param() $policy = [PSCustomObject]@{ MinPasswordLength = $null MaxPasswordAgeDays = $null MinPasswordAgeDays = $null PasswordHistoryLength = $null LockoutThreshold = $null LockoutDurationMinutes = $null LockoutObservationWindowMinutes = $null ForceLogoffHours = $null PasswordComplexity = $null ClearTextPassword = $null Source = 'Unavailable' } $isWindowsHost = $false try { $isWindowsHost = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( [System.Runtime.InteropServices.OSPlatform]::Windows) } catch { # RuntimeInformation missing on old .NET Framework (PS 5.1) $isWindowsHost = ($env:OS -eq 'Windows_NT') } if (-not $isWindowsHost) { Write-Verbose 'Get-LocalPasswordPolicy: not a Windows host; local SAM policy not applicable' return $policy } # --- Primary: NetUserModalsGet levels 0 and 3 --- $netApiOk = $false $modals0 = $null $modals3 = $null if (-not ([bool]('NetApiModals' -as [type]))) { $netApiModalsSource = @' using System; using System.Runtime.InteropServices; public static class NetApiModals { [StructLayout(LayoutKind.Sequential)] public struct USER_MODALS_INFO_0 { public uint usrmod0_min_passwd_len; public uint usrmod0_max_passwd_age; // seconds public uint usrmod0_min_passwd_age; // seconds public uint usrmod0_force_logoff; // seconds; 0xFFFFFFFF = TIMEQ_FOREVER public uint usrmod0_password_hist_len; } [StructLayout(LayoutKind.Sequential)] public struct USER_MODALS_INFO_3 { public uint usrmod3_lockout_duration; // seconds; 0xFFFFFFFF = TIMEQ_FOREVER public uint usrmod3_lockout_observation_window; // seconds; 0xFFFFFFFF = TIMEQ_FOREVER public uint usrmod3_lockout_threshold; } [DllImport("netapi32.dll", CharSet = CharSet.Unicode)] private static extern uint NetUserModalsGet(string server, int level, out IntPtr bufPtr); [DllImport("netapi32.dll")] private static extern uint NetApiBufferFree(IntPtr bufPtr); public static USER_MODALS_INFO_0 GetLevel0(out uint netError) { IntPtr buf = IntPtr.Zero; netError = NetUserModalsGet(null, 0, out buf); if (netError != 0) { return new USER_MODALS_INFO_0(); } try { return (USER_MODALS_INFO_0)Marshal.PtrToStructure(buf, typeof(USER_MODALS_INFO_0)); } finally { NetApiBufferFree(buf); } } public static USER_MODALS_INFO_3 GetLevel3(out uint netError) { IntPtr buf = IntPtr.Zero; netError = NetUserModalsGet(null, 3, out buf); if (netError != 0) { return new USER_MODALS_INFO_3(); } try { return (USER_MODALS_INFO_3)Marshal.PtrToStructure(buf, typeof(USER_MODALS_INFO_3)); } finally { NetApiBufferFree(buf); } } } '@ try { Add-Type -TypeDefinition $netApiModalsSource -ErrorAction Stop } catch { # Add-Type -TypeDefinition is blocked under Constrained Language Mode (JEA) Write-Verbose "Get-LocalPasswordPolicy: Add-Type NetApiModals failed: $($_.Exception.Message)" } } if ([bool]('NetApiModals' -as [type])) { $level0Error = [uint32]0 try { $level0 = [NetApiModals]::GetLevel0([ref]$level0Error) if ($level0Error -eq 0) { $modals0 = $level0 $netApiOk = $true } else { Write-Verbose "Get-LocalPasswordPolicy: NetUserModalsGet level 0 failed (netError $level0Error)" } } catch { Write-Verbose "Get-LocalPasswordPolicy: NetApi level 0 failed: $($_.Exception.Message)" } $level3Error = [uint32]0 try { $level3 = [NetApiModals]::GetLevel3([ref]$level3Error) if ($level3Error -eq 0) { $modals3 = $level3 $netApiOk = $true } else { Write-Verbose "Get-LocalPasswordPolicy: NetUserModalsGet level 3 failed (netError $level3Error)" } } catch { Write-Verbose "Get-LocalPasswordPolicy: NetApi level 3 failed: $($_.Exception.Message)" } if ($netApiOk) { $converted = ConvertTo-PolicyFromModals -Modals0 $modals0 -Modals3 $modals3 $policy.MinPasswordLength = $converted.MinPasswordLength $policy.MaxPasswordAgeDays = $converted.MaxPasswordAgeDays $policy.MinPasswordAgeDays = $converted.MinPasswordAgeDays $policy.PasswordHistoryLength = $converted.PasswordHistoryLength $policy.ForceLogoffHours = $converted.ForceLogoffHours $policy.LockoutThreshold = $converted.LockoutThreshold $policy.LockoutDurationMinutes = $converted.LockoutDurationMinutes $policy.LockoutObservationWindowMinutes = $converted.LockoutObservationWindowMinutes $policy.Source = 'NetApi' } } # --- Secedit (elevated): full fallback, or merge of the two NetApi cannot see --- $isAdmin = $false try { $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { $isAdmin = $false } $needSecedit = (-not $netApiOk) -or ($null -eq $policy.PasswordComplexity) $seceditData = $null if ($needSecedit -and $isAdmin) { $secedit = Get-Command 'secedit.exe' -ErrorAction SilentlyContinue if ($null -eq $secedit) { $secedit = Get-Command 'secedit' -ErrorAction SilentlyContinue } if ($null -ne $secedit) { $tempFile = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('wsa-secpol-{0}.inf' -f [guid]::NewGuid().ToString('N')) try { $null = & $secedit.Source '/export' '/cfg' $tempFile '/quiet' 2>&1 if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $tempFile)) { # INF is UTF-16 with BOM; Get-Content auto-detects. # [System Access] keys are English on all locales. $seceditData = @{} $inSection = $false foreach ($line in (Get-Content -LiteralPath $tempFile)) { $trimmed = $line.Trim() if ($trimmed -match '^\[') { $inSection = ($trimmed -ieq '[System Access]') continue } if ($inSection -and $trimmed -match '^([^=]+)=(.*)$') { $seceditData[$Matches[1].Trim()] = $Matches[2].Trim() } } } else { Write-Verbose "Get-LocalPasswordPolicy: secedit /export failed (exit $LASTEXITCODE)" } } catch { Write-Verbose "Get-LocalPasswordPolicy: secedit fallback failed: $($_.Exception.Message)" } finally { if (Test-Path -LiteralPath $tempFile -ErrorAction SilentlyContinue) { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } } } } if ($null -ne $seceditData) { if (-not $netApiOk) { # INF ages are already in days; -1 sentinel = never if ($seceditData.ContainsKey('MinimumPasswordLength') -and $seceditData['MinimumPasswordLength'] -match '^-?\d+$') { $policy.MinPasswordLength = [int]$seceditData['MinimumPasswordLength'] } if ($seceditData.ContainsKey('MaximumPasswordAge') -and $seceditData['MaximumPasswordAge'] -match '^-?\d+$') { $policy.MaxPasswordAgeDays = [int]$seceditData['MaximumPasswordAge'] } if ($seceditData.ContainsKey('MinimumPasswordAge') -and $seceditData['MinimumPasswordAge'] -match '^-?\d+$') { $policy.MinPasswordAgeDays = [int]$seceditData['MinimumPasswordAge'] } if ($seceditData.ContainsKey('PasswordHistorySize') -and $seceditData['PasswordHistorySize'] -match '^-?\d+$') { $policy.PasswordHistoryLength = [int]$seceditData['PasswordHistorySize'] } if ($seceditData.ContainsKey('LockoutBadCount') -and $seceditData['LockoutBadCount'] -match '^-?\d+$') { $policy.LockoutThreshold = [int]$seceditData['LockoutBadCount'] } if ($seceditData.ContainsKey('LockoutDuration') -and $seceditData['LockoutDuration'] -match '^-?\d+$') { $policy.LockoutDurationMinutes = [int]$seceditData['LockoutDuration'] } if ($seceditData.ContainsKey('ResetLockoutCount') -and $seceditData['ResetLockoutCount'] -match '^-?\d+$') { $policy.LockoutObservationWindowMinutes = [int]$seceditData['ResetLockoutCount'] } $policy.Source = 'Secedit' } if ($seceditData.ContainsKey('PasswordComplexity') -and $seceditData['PasswordComplexity'] -match '^-?\d+$') { $policy.PasswordComplexity = ([int]$seceditData['PasswordComplexity'] -eq 1) } if ($seceditData.ContainsKey('ClearTextPassword') -and $seceditData['ClearTextPassword'] -match '^-?\d+$') { $policy.ClearTextPassword = ([int]$seceditData['ClearTextPassword'] -eq 1) } # Only claim '+Secedit' when the merge actually contributed one of the # two fields NetApi cannot see; an INF lacking both leaves Source='NetApi'. if ($netApiOk -and (($null -ne $policy.PasswordComplexity) -or ($null -ne $policy.ClearTextPassword))) { $policy.Source = 'NetApi+Secedit' } } if ($policy.Source -eq 'Unavailable') { Write-Verbose 'Get-LocalPasswordPolicy: all resolution strategies failed; returning Unavailable' } return $policy } |