# CH-J Server Manager - fixed remote helper for chj.users on Windows (Windows PowerShell 5.1+ / PowerShell 7). # Core starts it through a short -EncodedCommand bootstrap that reads this script and one JSON argument object # from stdin, so it works with cmd.exe, Windows PowerShell or PowerShell 7 as the OpenSSH default shell. # Secrets (account passwords) and file contents only arrive inside the stdin JSON, never in a command line. # Output: one line "CHJ_RESULT:" or "CHJ_ERR::". param([string]$ArgsJson) Set-StrictMode -Version 2 $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $WarningPreference = 'SilentlyContinue' $AdminsSid = 'S-1-5-32-544' $UsersSid = 'S-1-5-32-545' $SystemSid = 'S-1-5-18' $SshDir = Join-Path $env:ProgramData 'ssh' $SshConfig = Join-Path $SshDir 'sshd_config' $AdminKeys = Join-Path $SshDir 'administrators_authorized_keys' $StateDir = Join-Path $env:ProgramData 'CH-J\chj-users' $TaskPrefix = 'CH-J chj.users sshd apply ' $MaxKeysBytes = 262144 function Fail([string]$Code, [string]$Message) { throw [System.Exception]::new("CHJ:${Code}:$Message") } function B64([string]$Text) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Text)) } # The leading comma keeps PowerShell from unrolling the byte array (an empty array would become $null). function FromB64([string]$Text) { ,[Convert]::FromBase64String($Text) } function Iso($Value) { if ($null -eq $Value) { return $null }; return ([datetime]$Value).ToUniversalTime().ToString('o') } function Sha256Hex([byte[]]$Bytes) { $sha = [Security.Cryptography.SHA256]::Create() try { return (($sha.ComputeHash($Bytes) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $sha.Dispose() } } function FileSha([string]$Path) { if (Test-Path -LiteralPath $Path -PathType Leaf) { return Sha256Hex ([IO.File]::ReadAllBytes($Path)) }; return 'absent' } function Prop($Object, [string]$Name) { if ($null -ne $Object -and $Object.PSObject.Properties[$Name]) { return $Object.$Name }; return $null } function Get-TokenInfo { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) $elevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) $groupSids = @($identity.Groups | ForEach-Object { $_.Value }) $denyOnly = @($identity.Claims | Where-Object { $_.Type -eq 'http://schemas.microsoft.com/ws/2008/06/identity/claims/denyonlysid' } | ForEach-Object { $_.Value }) $integrity = 'unknown' foreach ($line in @(& "$env:WINDIR\System32\whoami.exe" /groups /fo csv 2>$null)) { if ($line -match '"(S-1-16-\d+)"') { $integrity = @{ 'S-1-16-4096' = 'low'; 'S-1-16-8192' = 'medium'; 'S-1-16-8448' = 'medium-plus'; 'S-1-16-12288' = 'high'; 'S-1-16-16384' = 'system' }[$Matches[1]]; if (-not $integrity) { $integrity = $Matches[1] } } } # Group names as sshd compares them (lower-case, without domain), plus the unlocalized aliases of well-known SIDs. $groupNames = @() foreach ($group in @($identity.Groups)) { try { $groupNames += (($group.Translate([Security.Principal.NTAccount]).Value -split '\\')[-1]).ToLowerInvariant() } catch {} $alias = @{ 'S-1-5-32-544' = 'administrators'; 'S-1-5-32-545' = 'users'; 'S-1-5-32-585' = 'openssh users' }[$group.Value] if ($alias) { $groupNames += $alias } } [ordered]@{ name = $identity.Name groupNames = @($groupNames | Select-Object -Unique) sid = $identity.User.Value elevated = $elevated adminMember = ($elevated -or ($groupSids -contains $AdminsSid) -or ($denyOnly -contains $AdminsSid)) adminDenyOnly = ($denyOnly -contains $AdminsSid) integrity = $integrity isSystem = ($identity.User.Value -eq $SystemSid) } } function Require-Elevation { $token = Get-TokenInfo if ($token.elevated) { return } if ($token.adminMember) { Fail 'WINDOWS_ELEVATION_REQUIRED' 'The SSH session belongs to the Administrators group but its token is not elevated (UAC filtered token). CH-J does not bypass UAC.' } Fail 'WINDOWS_ELEVATION_REQUIRED' 'The SSH account is not a member of the local Administrators group; administrative operations are not available.' } # Standard accounts logged on over SSH (network logon) usually get "Access denied" from WMI/CIM, so the # registry and .NET are the primary sources and CIM only adds details when it is permitted. function Get-SystemInfo { $nt = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue $openssh = Get-ItemProperty 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue $build = [string](Prop $nt 'CurrentBuildNumber') $caption = [string](Prop $nt 'ProductName') if ($caption -match '^Windows 10' -and [int]("0$build") -ge 22000) { $caption = $caption -replace '^Windows 10', 'Windows 11' } $info = [ordered]@{ caption = $caption version = [Environment]::OSVersion.Version.ToString() build = $build displayVersion = [string](Prop $nt 'DisplayVersion') installationType = [string](Prop $nt 'InstallationType') productType = $null architecture = $env:PROCESSOR_ARCHITECTURE computerName = $env:COMPUTERNAME partOfDomain = $null domain = [string]$env:USERDNSDOMAIN domainRole = $null psVersion = $PSVersionTable.PSVersion.ToString() psEdition = [string]$PSVersionTable.PSEdition is64BitProcess = [Environment]::Is64BitProcess defaultShell = [string](Prop $openssh 'DefaultShell') localAccountsModule = [bool](Get-Command Get-LocalUser -ErrorAction SilentlyContinue) sshClient = [string]$env:SSH_CLIENT cim = $false } try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop $info.caption = [string]$os.Caption; $info.version = [string]$os.Version; $info.productType = [int]$os.ProductType $info.partOfDomain = [bool]$cs.PartOfDomain; $info.domain = [string]$cs.Domain; $info.domainRole = [int]$cs.DomainRole; $info.cim = $true } catch { $type = [string](Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ProductOptions' -ErrorAction SilentlyContinue).ProductType $info.productType = @{ 'WinNT' = 1; 'LanmanNT' = 2; 'ServerNT' = 3 }[$type] } $info } function Get-SshdPath { $path = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe'; if (Test-Path -LiteralPath $path) { return $path }; $cmd = Get-Command sshd.exe -ErrorAction SilentlyContinue; if ($cmd) { return $cmd.Source }; return $null } # sshd.exe never runs without a time limit: on some builds "sshd -T -C user=" cannot resolve # the account's groups without SYSTEM rights and then waits forever. function Invoke-Process([string]$File, [string[]]$Arguments, [int]$TimeoutMs = 15000) { $psi = New-Object Diagnostics.ProcessStartInfo $File $psi.Arguments = (@($Arguments) | ForEach-Object { '"' + ([string]$_).Replace('"', '') + '"' }) -join ' ' $psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true; $psi.RedirectStandardInput = $true; $psi.CreateNoWindow = $true $process = [Diagnostics.Process]::Start($psi) $process.StandardInput.Close() $out = $process.StandardOutput.ReadToEndAsync(); $err = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit($TimeoutMs)) { try { $process.Kill() } catch {}; return [ordered]@{ code = -2; output = ('timeout: ' + $err.Result).Trim() } } $process.WaitForExit() [ordered]@{ code = $process.ExitCode; output = (($out.Result + "`n" + $err.Result).Trim() -replace "`r", '') } } function Invoke-Sshd([string[]]$Arguments) { Invoke-Process (Get-SshdPath) $Arguments 15000 } $SshdRunner = @' param([string]$JobPath) $ErrorActionPreference = 'Stop' $job = Get-Content -LiteralPath $JobPath -Raw | ConvertFrom-Json $sshd = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe' $results = @() foreach ($run in @($job.runs)) { $psi = New-Object Diagnostics.ProcessStartInfo $sshd $psi.Arguments = (@($run) | ForEach-Object { '"' + ([string]$_).Replace('"', '') + '"' }) -join ' ' $psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true; $psi.RedirectStandardInput = $true; $psi.CreateNoWindow = $true $process = [Diagnostics.Process]::Start($psi); $process.StandardInput.Close() $out = $process.StandardOutput.ReadToEndAsync(); $err = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(15000)) { try { $process.Kill() } catch {}; $results += [ordered]@{ code = -2; output = 'timeout' }; continue } $process.WaitForExit() $results += [ordered]@{ code = $process.ExitCode; output = (($out.Result + "`n" + $err.Result).Trim() -replace "`r", '') } } [IO.File]::WriteAllText($job.output, (ConvertTo-Json -InputObject @($results) -Depth 4 -Compress), (New-Object Text.UTF8Encoding($false))) '@ # Runs a batch of sshd.exe argument lists as SYSTEM through a one-shot scheduled task (the script lives in the # SYSTEM/Administrators-only state directory). Returns one {code, output} per run. function Invoke-SshdBatch([object[]]$Runs) { if ((Get-TokenInfo).isSystem) { return @($Runs | ForEach-Object { Invoke-Sshd @($_) }) } Require-Elevation Initialize-StateDir $id = [guid]::NewGuid().ToString('N') $runner = Join-Path $StateDir 'sshd-query.ps1' [IO.File]::WriteAllText($runner, $SshdRunner, (New-Object Text.UTF8Encoding($true))) $jobPath = Join-Path $StateDir "query-$id.json"; $outputPath = Join-Path $StateDir "query-$id.out" [IO.File]::WriteAllText($jobPath, (ConvertTo-Json -InputObject ([ordered]@{ runs = @($Runs | ForEach-Object { ,@($_) }); output = $outputPath }) -Depth 5 -Compress), (New-Object Text.UTF8Encoding($false))) $bootstrap = "`$s=[IO.File]::ReadAllText('$runner'); & ([ScriptBlock]::Create(`$s)) '$jobPath'" $taskName = "CH-J chj.users sshd query $id" try { $action = New-ScheduledTaskAction -Execute "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument ("-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand " + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($bootstrap))) $principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5) Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null Start-ScheduledTask -TaskName $taskName $deadline = (Get-Date).AddSeconds(30 + 16 * @($Runs).Count) while (-not (Test-Path -LiteralPath $outputPath) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 } if (-not (Test-Path -LiteralPath $outputPath)) { Fail 'SSHD_QUERY_FAILED' 'The SYSTEM task evaluating sshd -T did not finish in time.' } Start-Sleep -Milliseconds 100 # Windows PowerShell 5.1 emits a parsed JSON array as one object; assigning it unrolls it. $parsed = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($outputPath)) return @($parsed) } finally { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue Remove-Item -LiteralPath $jobPath, $outputPath -Force -ErrorAction SilentlyContinue } } function Effective-Args([string]$Name, [string]$Address, [string]$ConfigPath) { $arguments = @('-T'); if ($ConfigPath) { $arguments += @('-f', $ConfigPath) } $arguments + @('-C', "user=$Name,host=$Address,addr=$Address") } function Filter-Effective($Result) { $keep = @($Result.output -split "`n" | Where-Object { $_ -match '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|authenticationmethods|permitrootlogin|denyusers|allowusers|denygroups|allowgroups|authorizedkeysfile|authorizedkeyscommand|addressfamily|listenaddress|port|strictmodes|usedns) ' -or ($Result.code -ne 0) }) [ordered]@{ code = $Result.code; text = ($keep -join "`n") } } function Get-SshInfo { $sshd = Get-SshdPath $service = Get-Service sshd -ErrorAction SilentlyContinue $info = [ordered]@{ installed = [bool]$sshd; path = $sshd; version = $null; service = $null; startType = $null; configPath = $SshConfig; configExists = (Test-Path -LiteralPath $SshConfig -PathType Leaf); configSha256 = 'absent'; configData = $null; configReadable = $false } if ($service) { $info.service = [string]$service.Status; $info.startType = [string]$service.StartType } if ($sshd) { $info.version = ((Invoke-Sshd @('-V')).output -split "`n")[0]; if (-not $info.version) { $info.version = (Get-Item -LiteralPath $sshd).VersionInfo.ProductVersion } } if ($info.configExists) { try { $bytes = [IO.File]::ReadAllBytes($SshConfig); $info.configSha256 = Sha256Hex $bytes; $info.configData = [Convert]::ToBase64String($bytes); $info.configReadable = $true } catch { $info.configReadable = $false } } $info } function Get-MemberSids($Group) { try { return @(Get-LocalGroupMember -SID $Group.SID.Value | ForEach-Object { [ordered]@{ sid = [string]$_.SID.Value; name = [string]$_.Name; objectClass = [string]$_.ObjectClass; source = [string]$_.PrincipalSource } }) } catch { # Get-LocalGroupMember fails on orphaned or cloud SIDs; ADSI still lists the members. $result = @() $adsi = [ADSI]("WinNT://./" + $Group.Name + ",group") foreach ($member in @($adsi.Invoke('Members'))) { $bytes = $member.GetType().InvokeMember('objectSid', 'GetProperty', $null, $member, [NullString]::Value) $sid = (New-Object Security.Principal.SecurityIdentifier($bytes, 0)).Value $name = $member.GetType().InvokeMember('Name', 'GetProperty', $null, $member, [NullString]::Value) $result += [ordered]@{ sid = $sid; name = [string]$name; objectClass = 'unknown'; source = 'unknown' } } return $result } } function Get-GroupsWithMembers { @(Get-LocalGroup | ForEach-Object { [ordered]@{ sid = $_.SID.Value; name = $_.Name; description = $_.Description; members = @(Get-MemberSids $_) } }) } function Convert-User($User) { [ordered]@{ name = $User.Name; sid = $User.SID.Value; enabled = [bool]$User.Enabled; fullName = [string]$User.FullName; description = [string]$User.Description source = [string]$User.PrincipalSource; lastLogon = (Iso $User.LastLogon); passwordLastSet = (Iso $User.PasswordLastSet); passwordRequired = [bool]$User.PasswordRequired passwordExpires = (Iso $User.PasswordExpires); accountExpires = (Iso $User.AccountExpires); userMayChangePassword = [bool]$User.UserMayChangePassword } } function Resolve-LocalUser($A) { $sid = [string](Prop $A 'sid') if ($sid) { if ($sid -notmatch '^S-1-5-21-\d+-\d+-\d+-\d+$') { Fail 'SID_INVALID' 'Invalid account SID.' } $user = Get-LocalUser -SID $sid -ErrorAction SilentlyContinue } else { $name = [string](Prop $A 'name') $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue } if (-not $user) { Fail 'USER_NOT_FOUND' 'The local account does not exist.' } $user } function Get-ProfilePath([string]$Sid) { $entry = Get-ItemProperty ("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\" + $Sid) -ErrorAction SilentlyContinue if ($entry -and (Prop $entry 'ProfileImagePath')) { return [Environment]::ExpandEnvironmentVariables([string]$entry.ProfileImagePath) } return $null } function Op-Probe { [ordered]@{ system = (Get-SystemInfo); token = (Get-TokenInfo) } } function Op-Inventory { if (-not (Get-Command Get-LocalUser -ErrorAction SilentlyContinue)) { Fail 'LOCALACCOUNTS_UNAVAILABLE' 'The Microsoft.PowerShell.LocalAccounts module is not available in this PowerShell.' } $users = @(Get-LocalUser | ForEach-Object { Convert-User $_ }) [ordered]@{ system = (Get-SystemInfo); token = (Get-TokenInfo); users = $users; groups = @(Get-GroupsWithMembers); ssh = (Get-SshInfo) } } function Get-KeyFileInfo([string]$Spec, [string]$Path, [bool]$Shared) { $info = [ordered]@{ spec = $Spec; path = $Path; shared = $Shared; type = 'absent'; dirExists = $false; reparse = $false; owner = $null; acl = @(); sha256 = 'absent'; data = $null; tooLarge = $false } $dir = Split-Path -Parent $Path if ($dir -and (Test-Path -LiteralPath $dir -PathType Container)) { $info.dirExists = $true if (((Get-Item -LiteralPath $dir -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $info.reparse = $true } } if (Test-Path -LiteralPath $Path) { $item = Get-Item -LiteralPath $Path -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $info.type = 'reparse'; $info.reparse = $true; return $info } if ($item.PSIsContainer) { $info.type = 'other'; return $info } $info.type = 'file' try { $acl = Get-Acl -LiteralPath $Path $info.owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value $info.acl = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]) | ForEach-Object { [ordered]@{ sid = $_.IdentityReference.Value; rights = [string]$_.FileSystemRights; type = [string]$_.AccessControlType; inherited = $_.IsInherited } }) if ($item.Length -gt $MaxKeysBytes) { $info.tooLarge = $true; return $info } $bytes = [IO.File]::ReadAllBytes($Path) $info.sha256 = Sha256Hex $bytes $info.data = [Convert]::ToBase64String($bytes) } catch { $info.type = 'unreadable' } } elseif (-not $info.dirExists) { $info.type = 'absent-dir' } $info } function Expand-KeysSpec([string]$Spec, [string]$ProfilePath, [string]$Name) { $value = $Spec.Replace('__PROGRAMDATA__', $env:ProgramData) if ($value -match '%[^%hu]') { return $null } $value = $value.Replace('%%', [string][char]1) if ($value -match '%h' -and -not $ProfilePath) { return $null } $value = $value.Replace('%h', [string]$ProfilePath).Replace('%u', $Name).Replace([string][char]1, '%').Replace('/', '\') if (-not [IO.Path]::IsPathRooted($value)) { if (-not $ProfilePath) { return $null }; $value = Join-Path $ProfilePath $value } [IO.Path]::GetFullPath($value) } function Op-Detail($A) { $user = Resolve-LocalUser $A $sid = $user.SID.Value $groups = @(Get-GroupsWithMembers | Where-Object { @($_.members | ForEach-Object { $_.sid }) -contains $sid } | ForEach-Object { [ordered]@{ sid = $_.sid; name = $_.name } }) $token = Get-TokenInfo $profilePath = Get-ProfilePath $sid $ssh = Get-SshInfo $result = [ordered]@{ user = (Convert-User $user); groups = $groups; profilePath = $profilePath; token = $token; system = (Get-SystemInfo); ssh = $ssh; syntax = $null; effective4 = $null; effective6 = $null; keyFiles = @() } $specs = @('.ssh/authorized_keys') if (@($groups | ForEach-Object { $_.sid }) -contains $AdminsSid) { $specs = @('__PROGRAMDATA__/ssh/administrators_authorized_keys') } if ($token.elevated -and $ssh.installed) { $sshName = $user.Name.ToLowerInvariant() $addr4 = [string](Prop $A 'addr4'); $addr6 = [string](Prop $A 'addr6') if (($addr4 -and $addr4 -notmatch '^[0-9a-fA-F:.]{2,45}$') -or ($addr6 -and $addr6 -notmatch '^[0-9a-fA-F:.]{2,45}$')) { Fail 'ARGUMENTS_INVALID' 'Invalid address.' } $runs = @(,@('-t')) if ($addr4) { $runs += ,(Effective-Args $sshName $addr4 $null) } if ($addr6) { $runs += ,(Effective-Args $sshName $addr6 $null) } try { $batch = @(Invoke-SshdBatch $runs) $result.syntax = [ordered]@{ code = $batch[0].code; output = $batch[0].output } $index = 1 if ($addr4) { $result.effective4 = Filter-Effective $batch[$index]; $index++ } if ($addr6) { $result.effective6 = Filter-Effective $batch[$index] } } catch { $result.syntax = [ordered]@{ code = $null; output = $_.Exception.Message } } $source = $result.effective4; if (-not $source) { $source = $result.effective6 } if ($source -and $source.code -eq 0) { $line = @($source.text -split "`n" | Where-Object { $_ -match '^authorizedkeysfile ' }) | Select-Object -First 1 if ($line) { $specs = @(($line -replace '^authorizedkeysfile\s+', '') -split '\s+' | Where-Object { $_ -and $_ -ne 'none' }) } } } foreach ($spec in @($specs | Select-Object -First 4)) { $path = Expand-KeysSpec $spec $profilePath $user.Name if (-not $path) { $result.keyFiles += [ordered]@{ spec = $spec; path = $null; type = 'unresolved'; shared = $false }; continue } $shared = $path.StartsWith($env:ProgramData, [StringComparison]::OrdinalIgnoreCase) $selfOnly = (-not $token.elevated) -and ($token.sid -ne $sid) if ($selfOnly -or ($shared -and -not $token.elevated)) { $result.keyFiles += [ordered]@{ spec = $spec; path = $path; type = 'needs-elevation'; shared = $shared }; continue } $result.keyFiles += (Get-KeyFileInfo $spec $path $shared) } $result } function Assert-Mutable($User, [object]$A) { $rid = [int](($User.SID.Value -split '-')[-1]) if ($rid -lt 1000) { Fail 'PROTECTED_USER' 'Built-in accounts (Administrator, Guest, DefaultAccount, WDAGUtilityAccount) are protected.' } if ($User.SID.Value -eq (Get-TokenInfo).sid) { Fail 'PROTECTED_USER' 'The account of the active SSH session is protected.' } } function Get-EnabledAdmins { @(Get-MemberSids (Get-LocalGroup -SID $AdminsSid) | Where-Object { $_.sid -match '^S-1-5-21-' } | ForEach-Object { $member = $_ $local = Get-LocalUser -SID $member.sid -ErrorAction SilentlyContinue if ($local) { if ($local.Enabled) { $member.sid } } else { $member.sid } }) } function Assert-NotLastAdmin($User) { $others = @(Get-EnabledAdmins | Where-Object { $_ -ne $User.SID.Value }) if ($others.Count -eq 0) { Fail 'LAST_ADMIN' 'This is the last enabled member of the local Administrators group.' } } function New-SecurePassword([string]$Plain) { if (-not $Plain -or $Plain.Length -gt 256 -or $Plain -match "[`0`r`n]") { Fail 'PASSWORD_INVALID' 'Invalid password.' } ConvertTo-SecureString -String $Plain -AsPlainText -Force } function Resolve-Group([string]$Sid) { if ($Sid -notmatch '^S-1-5-(32-\d+|21-\d+-\d+-\d+-\d+)$') { Fail 'GROUP_INVALID' 'Invalid group SID.' } $group = Get-LocalGroup -SID $Sid -ErrorAction SilentlyContinue if (-not $group) { Fail 'GROUP_NOT_FOUND' 'The local group does not exist.' } $group } function Op-Create($A) { Require-Elevation $name = [string]$A.name if ($name -notmatch '^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$' -or $name -match '[. ]$') { Fail 'NAME_INVALID' 'Invalid account name.' } if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) { Fail 'USER_EXISTS' 'The account already exists.' } if (Get-LocalGroup -Name $name -ErrorAction SilentlyContinue) { Fail 'GROUP_EXISTS' 'A local group with this name exists.' } $params = @{ Name = $name; Description = [string](Prop $A 'description'); FullName = [string](Prop $A 'fullName') } $password = [string](Prop $A 'password') if ($password) { $params.Password = New-SecurePassword $password } else { $params.NoPassword = $true } $user = New-LocalUser @params $steps = @([ordered]@{ step = 'create'; status = 'ok'; message = '' }) $result = 'ok' foreach ($groupSid in @(@($UsersSid) + @(Prop $A 'groups') | Where-Object { $_ } | Select-Object -Unique)) { try { Add-LocalGroupMember -SID (Resolve-Group $groupSid).SID.Value -Member $user.SID.Value; $steps += [ordered]@{ step = "group $groupSid"; status = 'ok'; message = '' } } catch { $steps += [ordered]@{ step = "group $groupSid"; status = 'failed'; message = $_.Exception.Message }; $result = 'partial' } } if ((Prop $A 'admin') -eq $true) { try { Add-LocalGroupMember -SID $AdminsSid -Member $user.SID.Value; $steps += [ordered]@{ step = 'administrators'; status = 'ok'; message = '' } } catch { $steps += [ordered]@{ step = 'administrators'; status = 'failed'; message = $_.Exception.Message }; $result = 'partial' } } [ordered]@{ result = $result; steps = $steps; sid = $user.SID.Value } } function Op-Account($A) { Require-Elevation $user = Resolve-LocalUser $A Assert-Mutable $user $A $action = [string]$A.action $isAdmin = @(Get-MemberSids (Get-LocalGroup -SID $AdminsSid) | ForEach-Object { $_.sid }) -contains $user.SID.Value switch ($action) { 'disable' { if ($isAdmin) { Assert-NotLastAdmin $user }; Disable-LocalUser -SID $user.SID.Value } 'enable' { Enable-LocalUser -SID $user.SID.Value } 'password' { Set-LocalUser -SID $user.SID.Value -Password (New-SecurePassword ([string]$A.password)) } 'fullName' { $value = [string]$A.value; if ($value.Length -gt 256 -or $value -match '[\x00-\x1f]') { Fail 'COMMENT_INVALID' 'Invalid full name.' }; Set-LocalUser -SID $user.SID.Value -FullName $value } 'description' { $value = [string]$A.value; if ($value.Length -gt 48 -or $value -match '[\x00-\x1f]') { Fail 'COMMENT_INVALID' 'The description may contain up to 48 characters.' }; Set-LocalUser -SID $user.SID.Value -Description $value } 'group-add' { $group = Resolve-Group ([string]$A.group); Add-LocalGroupMember -SID $group.SID.Value -Member $user.SID.Value } 'group-remove' { $group = Resolve-Group ([string]$A.group) if ($group.SID.Value -eq $AdminsSid) { Assert-NotLastAdmin $user } Remove-LocalGroupMember -SID $group.SID.Value -Member $user.SID.Value } 'admin-grant' { Add-LocalGroupMember -SID $AdminsSid -Member $user.SID.Value } 'admin-revoke' { Assert-NotLastAdmin $user; Remove-LocalGroupMember -SID $AdminsSid -Member $user.SID.Value } default { Fail 'ACTION_INVALID' 'Unsupported account action.' } } [ordered]@{ result = 'ok'; steps = @([ordered]@{ step = $action; status = 'ok'; message = '' }) } } function Op-Delete($A) { Require-Elevation $user = Resolve-LocalUser $A Assert-Mutable $user $A if (@(Get-MemberSids (Get-LocalGroup -SID $AdminsSid) | ForEach-Object { $_.sid }) -contains $user.SID.Value) { Assert-NotLastAdmin $user } $sid = $user.SID.Value $profile = Get-CimInstance Win32_UserProfile -Filter "SID='$sid'" -ErrorAction SilentlyContinue $removeProfile = (Prop $A 'removeProfile') -eq $true # Windows may keep a profile hive loaded for a while after SSH logons; only profile removal depends on it. if ($removeProfile -and $profile -and $profile.Loaded) { Fail 'PROFILE_IN_USE' 'The user profile is still loaded (the user is logged on or Windows has not released it yet). Try again later or delete the account without its profile.' } Remove-LocalUser -SID $sid $steps = @([ordered]@{ step = 'delete'; status = 'ok'; message = '' }) $result = 'ok' if (-not $removeProfile -and $profile) { $steps += [ordered]@{ step = 'profile'; status = 'kept'; message = [string]$profile.LocalPath } } if ($removeProfile -and $profile) { try { Remove-CimInstance -InputObject $profile; $steps += [ordered]@{ step = 'profile'; status = 'ok'; message = [string]$profile.LocalPath } } catch { $steps += [ordered]@{ step = 'profile'; status = 'failed'; message = $_.Exception.Message }; $result = 'partial' } } [ordered]@{ result = $result; steps = $steps } } function Set-StrictKeyAcl([string]$Path, [string]$OwnerSid, [bool]$Shared) { $acl = New-Object Security.AccessControl.FileSecurity $acl.SetAccessRuleProtection($true, $false) $grant = @($SystemSid, $AdminsSid) if (-not $Shared) { $grant += $OwnerSid } foreach ($sid in $grant) { $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier($sid)), 'FullControl', 'Allow'))) } $owner = $OwnerSid; if ($Shared) { $owner = $AdminsSid } $acl.SetOwner((New-Object Security.Principal.SecurityIdentifier($owner))) Set-Acl -LiteralPath $Path -AclObject $acl } function Op-KeysWrite($A) { $user = Resolve-LocalUser $A $token = Get-TokenInfo $self = $token.sid -eq $user.SID.Value if (-not $self) { Require-Elevation } $path = [string]$A.path # The path is recomputed on the server; Core cannot point the write at an arbitrary file. $detail = Op-Detail ([pscustomobject]@{ sid = $user.SID.Value; addr4 = [string](Prop $A 'addr4'); addr6 = [string](Prop $A 'addr6') }) $file = @($detail.keyFiles | Where-Object { $_.path -and $_.path -eq $path }) | Select-Object -First 1 if (-not $file) { Fail 'KEYS_PATH_UNSUPPORTED' 'The key file is not an effective AuthorizedKeysFile of this account.' } if ($file.type -eq 'needs-elevation') { Require-Elevation } if ($file.reparse -or $file.type -eq 'reparse') { Fail 'KEYS_SYMLINK' 'The key file or its directory is a reparse point (symbolic link or junction); it was not changed.' } if ($file.type -notin @('file', 'absent', 'absent-dir')) { Fail 'KEYS_NOT_REGULAR' 'The key file is not a regular file.' } if ((FileSha $path) -ne [string]$A.expected) { Fail 'KEYS_CHANGED' 'The key file changed after it was read. Reload and try again.' } $bytes = FromB64 ([string]$A.data) if ($bytes.Length -gt $MaxKeysBytes) { Fail 'KEYS_FILE_TOO_LARGE' 'The key file would be too large.' } $dir = Split-Path -Parent $path if (-not (Test-Path -LiteralPath $dir -PathType Container)) { if ($file.shared) { Fail 'KEYS_DIRECTORY_FAILED' 'The OpenSSH ProgramData directory is missing.' } $profilePath = $detail.profilePath if (-not $profilePath -or -not (Test-Path -LiteralPath $profilePath -PathType Container)) { Fail 'PROFILE_MISSING' 'The user profile does not exist yet (the account has never logged on).' } New-Item -ItemType Directory -Path $dir | Out-Null } $temp = Join-Path $dir ('.chj-keys-' + [guid]::NewGuid().ToString('N') + '.tmp') try { [IO.File]::WriteAllBytes($temp, $bytes) if (Test-Path -LiteralPath $path) { # ReplaceFile keeps the target's owner and DACL, so existing ACLs are not rewritten. [IO.File]::Replace($temp, $path, [NullString]::Value) } else { Set-StrictKeyAcl $temp $user.SID.Value $file.shared Move-Item -LiteralPath $temp -Destination $path } } finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force } } $after = Get-KeyFileInfo $file.spec $path $file.shared [ordered]@{ result = 'ok'; sha256 = $after.sha256; owner = $after.owner; acl = $after.acl } } function Initialize-StateDir { if (-not (Test-Path -LiteralPath $StateDir)) { New-Item -ItemType Directory -Path $StateDir -Force | Out-Null } # Only SYSTEM and Administrators may write here: the SYSTEM task executes files from this directory. $acl = New-Object Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) foreach ($sid in @($SystemSid, $AdminsSid)) { $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier($sid)), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) } $acl.SetOwner((New-Object Security.Principal.SecurityIdentifier($AdminsSid))) Set-Acl -LiteralPath $StateDir -AclObject $acl } function Get-ActiveApply { @(Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName.StartsWith($TaskPrefix) -and $_.State -eq 'Running' }) } function Op-SshStage($A) { Require-Elevation if (-not (Get-SshdPath)) { Fail 'SSHD_MISSING' 'OpenSSH Server (sshd.exe) is not installed.' } if (@(Get-ActiveApply).Count) { Fail 'SSH_APPLY_IN_PROGRESS' 'Another SSH configuration change is still being applied or awaiting confirmation.' } if ((FileSha $SshConfig) -ne [string]$A.expected) { Fail 'SSH_POLICY_CHANGED' 'sshd_config changed after it was read.' } Initialize-StateDir $id = [guid]::NewGuid().ToString('N') $candidate = Join-Path $StateDir "candidate-$id.conf" [IO.File]::WriteAllBytes($candidate, (FromB64 ([string]$A.data))) Get-ChildItem -LiteralPath $StateDir -Filter 'candidate-*.conf' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddHours(-1) } | Remove-Item -Force -ErrorAction SilentlyContinue $keys = @() $runs = @(,@('-t')); $runs += ,@('-t', '-f', $candidate) foreach ($tuple in @($A.tuples)) { $name = [string]$tuple.user; $addr = [string]$tuple.addr if ($name -notmatch '^[a-z0-9_][a-z0-9._-]{0,19}$' -or $addr -notmatch '^[0-9a-fA-F:.]{2,45}$') { Fail 'ARGUMENTS_INVALID' 'Invalid verification target.' } $keys += "$name|$addr" $runs += ,(Effective-Args $name $addr $null) $runs += ,(Effective-Args $name $addr $candidate) } $batch = @(Invoke-SshdBatch $runs) if ($batch[0].code -ne 0) { Remove-Item -LiteralPath $candidate -Force; Fail 'SSH_CONFIG_ALREADY_INVALID' $batch[0].output } $test = $batch[1] $before = [ordered]@{}; $after = [ordered]@{} for ($i = 0; $i -lt $keys.Count; $i++) { $before[$keys[$i]] = Filter-Effective $batch[2 + 2 * $i] $after[$keys[$i]] = Filter-Effective $batch[3 + 2 * $i] } if ($test.code -ne 0) { Remove-Item -LiteralPath $candidate -Force } [ordered]@{ id = $id; syntaxCode = $test.code; syntaxOutput = $test.output; before = $before; after = $after; candidateSha256 = (Sha256Hex (FromB64 ([string]$A.data))) } } # Runs as SYSTEM from a scheduled task, independent of the SSH connection and of CH-J. $ApplyScript = @' param([string]$Id) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $StateDir = Join-Path $env:ProgramData 'CH-J\chj-users' $SshConfig = Join-Path $env:ProgramData 'ssh\sshd_config' $sshd = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe' $params = Get-Content -LiteralPath (Join-Path $StateDir "apply-$Id.json") -Raw | ConvertFrom-Json $statusPath = Join-Path $StateDir "status-$Id.json" function Status([string]$State, [string]$Message) { [ordered]@{ id = $Id; state = $State; message = $Message; at = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress | Set-Content -LiteralPath $statusPath -Encoding ASCII } function Sha([string]$Path) { $s = [Security.Cryptography.SHA256]::Create(); try { (($s.ComputeHash([IO.File]::ReadAllBytes($Path)) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $s.Dispose() } } function RestartSshd { Restart-Service sshd -Force; (Get-Service sshd).WaitForStatus('Running', [TimeSpan]::FromSeconds(30)) } $backup = Join-Path $StateDir "backup-$Id.conf" $backupAcl = Join-Path $StateDir "backup-$Id.sddl" $candidate = Join-Path $StateDir "candidate-$Id.conf" $token = Join-Path $StateDir "confirm-$Id.token" function Restore([string]$Reason) { try { $temp = "$SshConfig.chj-restore" Copy-Item -LiteralPath $backup -Destination $temp -Force $acl = Get-Acl -LiteralPath $SshConfig; $acl.SetSecurityDescriptorSddlForm((Get-Content -LiteralPath $backupAcl -Raw)); Set-Acl -LiteralPath $temp -AclObject $acl [IO.File]::Replace($temp, $SshConfig, [NullString]::Value) RestartSshd Status 'rolled-back' $Reason } catch { Status 'rollback-failed' ("$Reason; rollback error: " + $_.Exception.Message) } } try { Status 'armed' 'Rollback guard active.' if ((Sha $SshConfig) -ne $params.expected) { Status 'aborted' 'sshd_config changed before apply.'; return } if ((Sha $candidate) -ne $params.candidateSha256) { Status 'aborted' 'Candidate changed before apply.'; return } Copy-Item -LiteralPath $SshConfig -Destination $backup -Force (Get-Acl -LiteralPath $SshConfig).Sddl | Set-Content -LiteralPath $backupAcl -Encoding ASCII $temp = "$SshConfig.chj-new" Copy-Item -LiteralPath $candidate -Destination $temp -Force $acl = Get-Acl -LiteralPath $SshConfig; Set-Acl -LiteralPath $temp -AclObject $acl [IO.File]::Replace($temp, $SshConfig, [NullString]::Value) # ProcessStartInfo instead of Start-Process: Windows PowerShell 5.1 may report a $null ExitCode otherwise. $psi = New-Object Diagnostics.ProcessStartInfo $sshd, '-t' $psi.UseShellExecute = $false; $psi.RedirectStandardError = $true; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardInput = $true; $psi.CreateNoWindow = $true $check = [Diagnostics.Process]::Start($psi); $check.StandardInput.Close() $checkErr = $check.StandardError.ReadToEndAsync(); $null = $check.StandardOutput.ReadToEndAsync() if (-not $check.WaitForExit(20000)) { try { $check.Kill() } catch {}; Restore 'sshd -t did not finish after writing the configuration.'; return } $check.WaitForExit() if ($check.ExitCode -ne 0) { Restore ('sshd -t failed after writing the configuration: ' + $checkErr.Result); return } try { RestartSshd } catch { Restore ('sshd restart failed: ' + $_.Exception.Message); return } Status 'awaiting-confirmation' 'sshd restarted with the new configuration.' $deadline = (Get-Date).AddSeconds([int]$params.timeoutSeconds) while ((Get-Date) -lt $deadline) { if (Test-Path -LiteralPath $token) { if ((Get-Content -LiteralPath $token -Raw).Trim() -eq $params.nonce) { Status 'confirmed' 'A new SSH login confirmed the change.'; return } } Start-Sleep -Milliseconds 500 } Restore 'No confirmation from a new SSH login before the timeout.' } catch { Restore ('Apply failed: ' + $_.Exception.Message) } finally { Remove-Item -LiteralPath $candidate, $token -Force -ErrorAction SilentlyContinue Get-ChildItem -LiteralPath $StateDir -Filter 'backup-*' | Sort-Object LastWriteTime -Descending | Select-Object -Skip 20 | Remove-Item -Force -ErrorAction SilentlyContinue } '@ function Op-SshCommit($A) { Require-Elevation $id = [string]$A.id if ($id -notmatch '^[0-9a-f]{32}$') { Fail 'ARGUMENTS_INVALID' 'Invalid apply id.' } $nonce = [string]$A.nonce if ($nonce -notmatch '^[0-9a-f]{32,64}$') { Fail 'ARGUMENTS_INVALID' 'Invalid nonce.' } $timeout = [int]$A.timeoutSeconds if ($timeout -lt 30 -or $timeout -gt 600) { Fail 'ARGUMENTS_INVALID' 'Invalid timeout.' } if (@(Get-ActiveApply).Count) { Fail 'SSH_APPLY_IN_PROGRESS' 'Another SSH configuration change is still being applied.' } Initialize-StateDir $candidate = Join-Path $StateDir "candidate-$id.conf" if (-not (Test-Path -LiteralPath $candidate)) { Fail 'SSH_POLICY_CHANGED' 'The staged configuration no longer exists.' } if ((FileSha $candidate) -ne [string]$A.candidateSha256) { Fail 'SSH_POLICY_CHANGED' 'The staged configuration changed.' } if ((FileSha $SshConfig) -ne [string]$A.expected) { Fail 'SSH_POLICY_CHANGED' 'sshd_config changed after it was read.' } $scriptPath = Join-Path $StateDir 'apply.ps1' [IO.File]::WriteAllText($scriptPath, $ApplyScript, (New-Object Text.UTF8Encoding($true))) [ordered]@{ expected = [string]$A.expected; candidateSha256 = [string]$A.candidateSha256; nonce = $nonce; timeoutSeconds = $timeout } | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $StateDir "apply-$id.json") -Encoding ASCII $taskName = $TaskPrefix + $id $action = New-ScheduledTaskAction -Execute "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`" -Id $id" $principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds ($timeout + 180)) -MultipleInstances IgnoreNew Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null Start-ScheduledTask -TaskName $taskName $deadline = (Get-Date).AddSeconds(20) $status = $null while ((Get-Date) -lt $deadline) { $statusPath = Join-Path $StateDir "status-$id.json" if (Test-Path -LiteralPath $statusPath) { $status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json; if ($status.state -ne 'armed') { break } } Start-Sleep -Milliseconds 300 } if (-not $status) { Fail 'SSH_APPLY_NOT_STARTED' 'The rollback guard task did not start; nothing was changed.' } [ordered]@{ id = $id; state = [string]$status.state; message = [string]$status.message } } function Op-SshConfirm($A) { Require-Elevation $id = [string]$A.id; $nonce = [string]$A.nonce if ($id -notmatch '^[0-9a-f]{32}$' -or $nonce -notmatch '^[0-9a-f]{32,64}$') { Fail 'ARGUMENTS_INVALID' 'Invalid confirmation.' } $statusPath = Join-Path $StateDir "status-$id.json" if (-not (Test-Path -LiteralPath $statusPath)) { Fail 'SSH_APPLY_UNKNOWN' 'The apply task is unknown.' } $status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json if ($status.state -eq 'awaiting-confirmation' -and (Prop $A 'confirm') -eq $true) { Set-Content -LiteralPath (Join-Path $StateDir "confirm-$id.token") -Value $nonce -Encoding ASCII } $deadline = (Get-Date).AddSeconds([int](Prop $A 'waitSeconds')) do { $status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json if ($status.state -in @('confirmed', 'rolled-back', 'rollback-failed', 'aborted')) { break } Start-Sleep -Milliseconds 400 } while ((Get-Date) -lt $deadline) if ($status.state -in @('confirmed', 'rolled-back', 'rollback-failed', 'aborted')) { Unregister-ScheduledTask -TaskName ($TaskPrefix + $id) -Confirm:$false -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $StateDir "apply-$id.json") -Force -ErrorAction SilentlyContinue } [ordered]@{ id = $id; state = [string]$status.state; message = [string]$status.message; service = [string](Get-Service sshd).Status } } $Operations = @{ 'probe' = { Op-Probe }; 'inventory' = { Op-Inventory }; 'detail' = { param($a) Op-Detail $a }; 'create' = { param($a) Op-Create $a } 'account' = { param($a) Op-Account $a }; 'delete' = { param($a) Op-Delete $a }; 'keys-write' = { param($a) Op-KeysWrite $a } 'ssh-stage' = { param($a) Op-SshStage $a }; 'ssh-commit' = { param($a) Op-SshCommit $a }; 'ssh-confirm' = { param($a) Op-SshConfirm $a } } try { $A = $ArgsJson | ConvertFrom-Json $op = [string]$A.op if (-not $Operations.ContainsKey($op)) { Fail 'ACTION_INVALID' 'Unsupported operation.' } $value = & $Operations[$op] $A [Console]::Out.WriteLine('CHJ_RESULT:' + (B64 ($value | ConvertTo-Json -Depth 10 -Compress))) } catch { $exception = $_.Exception while ($exception.InnerException -and -not $exception.Message.StartsWith('CHJ:')) { $exception = $exception.InnerException } $message = $exception.Message $code = 'WINDOWS_OPERATION_FAILED' if ($message -match '^CHJ:([A-Z0-9_]+):([\s\S]*)$') { $code = $Matches[1]; $message = $Matches[2] } else { switch -Regex ($exception.GetType().FullName) { 'AccessDenied|UnauthorizedAccess' { $code = 'ACCESS_DENIED' } 'UserNotFound' { $code = 'USER_NOT_FOUND' } 'UserExists|NameInUse' { $code = 'USER_EXISTS' } 'GroupNotFound' { $code = 'GROUP_NOT_FOUND' } 'MemberExists' { $code = 'GROUP_MEMBER_EXISTS' } 'MemberNotFound' { $code = 'GROUP_NOT_MEMBER' } 'InvalidPassword' { $code = 'PASSWORD_POLICY' } 'InvalidName' { $code = 'NAME_INVALID' } } if ($code -eq 'WINDOWS_OPERATION_FAILED' -and $exception.HResult -eq -2147024891) { $code = 'ACCESS_DENIED' } } [Console]::Out.WriteLine('CHJ_ERR:' + $code + ':' + (B64 $message)) }