<# ssh-onboard.ps1 - grant an external party hardened SSH access to this Windows host. Windows counterpart of ssh-onboard.sh. Run it, read the plan, run it again with -Apply. Nothing changes without -Apply. Designed to be read top to bottom before you execute it elevated - one file, no downloads at runtime, no hidden steps. What it does: 1. Creates a dedicated local user (random, discarded password - the account is key-only via sshd policy). 2. Installs the external party's SSH public key with restrictive per-key options (restrict,pty[,from="..."]) into a per-user key file under %ProgramData%\ssh\ssh-onboard\\ with a strict ACL (SYSTEM + Administrators only). 3. Optionally (-Admin) adds the user to the local Administrators group (language-independent via SID S-1-5-32-544). 4. Appends a clearly marked Match block to sshd_config that points AuthorizedKeysFile at the per-user key file and makes the account key-only (no password, no forwarding). Validated with sshd -t before the service is restarted; on failure the previous config is restored. Why the Match block is NOT optional on Windows (unlike the Linux version): - A freshly created account has no profile directory yet, so the usual C:\Users\\.ssh\authorized_keys does not exist before first logon. - For members of Administrators, Windows sshd normally consults the SHARED %ProgramData%\ssh\administrators_authorized_keys - a key placed there would authenticate against EVERY admin account. The explicit per-user AuthorizedKeysFile override avoids both problems: the key works for this one account only, admin or not. Honest limits: - Windows sshd cannot reload its configuration; the script RESTARTS the sshd service. Existing SSH sessions drop for a moment. - Reachability from outside (NAT, port forwarding, VPN, Windows firewall rules beyond what the OpenSSH server setup created) is out of scope. Undo everything (also printed after -Apply): Remove-LocalUser Remove-Item -Recurse %ProgramData%\ssh\ssh-onboard\ remove the marked block from %ProgramData%\ssh\sshd_config, then Restart-Service sshd Usage: powershell -ExecutionPolicy Bypass -File .\ssh-onboard.ps1 # dry run powershell -ExecutionPolicy Bypass -File .\ssh-onboard.ps1 -Apply # execute Options: -User NAME -Key "KEY" -KeyFile FILE -From "IP[,IP]" -Admin -NoRestrict -Apply -Yes #> # Write-Host is intentional here: this is an interactive console tool and its # output is the user interface, not pipeline data. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] [CmdletBinding()] param( [string]$User = 'ext-ops', [string]$Key = '', [string]$KeyFile = '', [string]$From = '', [switch]$Admin, [switch]$NoRestrict, [switch]$Apply, [switch]$Yes ) $ErrorActionPreference = 'Stop' $Version = '1.0.2' # -- Personalization ---------------------------------------------------------- # Distributors: put your public key and identity here, so recipients only # have to check the fingerprint you publish and run the script. $DefaultPubkey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxGfIJpDOUhggucuFBnkjpSqPz1aFBQGfhMoo0AgqOT onboard@jozapf.de' $DefaultKeyOwner = 'Jo Zapf - https://jozapf.de (fingerprint published at https://toolbox.jozapf.de/ssh-onboard.html)' # Right-click "Run with PowerShell" (and double-click) opens a transient # console that closes together with the script - pause before exiting so the # output stays readable. Detected via the script name in the process command # line (an interactive console session does not carry it). $script:PauseOnExit = ([Environment]::GetCommandLineArgs() -join ' ') -match [regex]::Escape((Split-Path -Leaf $PSCommandPath)) function Done([int]$Code) { if ($script:PauseOnExit -and -not [Console]::IsInputRedirected) { Read-Host 'Press Enter to exit' | Out-Null } exit $Code } function Fail([string]$Msg) { Write-Host "ERROR: $Msg"; Done 1 } function Note([string]$Msg) { Write-Host $Msg } # -- Preconditions (read-only) ------------------------------------------------ $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Note 'This script needs administrator rights.' Note 'Right-click "Run with PowerShell" starts WITHOUT them and cannot pass -Apply.' Note 'Instead: Start menu > "Windows PowerShell" > right-click > "Run as administrator",' Note 'then (the admin console starts in System32, so change to this folder first):' Note " cd `"$PSScriptRoot`"" Note ' powershell -ExecutionPolicy Bypass -File .\ssh-onboard.ps1' Fail 'administrator rights required.' } if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) { Fail 'OpenSSH Server (sshd) is not installed. Install: Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0' } if (-not (Get-Command New-LocalUser -ErrorAction SilentlyContinue)) { Fail 'LocalAccounts cmdlets unavailable - run this in Windows PowerShell (powershell.exe).' } $svc = Get-CimInstance Win32_Service -Filter "Name='sshd'" $SshdExe = ($svc.PathName -replace '"', '').Trim() if (-not (Test-Path $SshdExe)) { $SshdExe = Join-Path $env:SystemRoot 'System32\OpenSSH\sshd.exe' } if (-not (Test-Path $SshdExe)) { Fail 'sshd.exe not found.' } $SshKeygen = Join-Path (Split-Path $SshdExe) 'ssh-keygen.exe' if (-not (Test-Path $SshKeygen)) { Fail 'ssh-keygen.exe not found. Install the client tools: Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0' } # Windows SAM account names: max 20 chars. if ($User -notmatch '^[a-z_][a-z0-9_-]{0,19}$') { Fail "invalid user name: $User" } $Pubkey = $DefaultPubkey $KeyOwner = $DefaultKeyOwner if ($KeyFile) { $Pubkey = (Get-Content -Raw -Path $KeyFile).Trim(); $KeyOwner = "(from file: $KeyFile)" } if ($Key) { $Pubkey = $Key.Trim(); $KeyOwner = '(given on the command line)' } if (-not $Pubkey) { Fail 'this copy has no built-in key - pass -Key "ssh-ed25519 AAAA..." or -KeyFile key.pub' } if ((($Pubkey -split "`r?`n") | Where-Object { $_ -match '\S' }).Count -ne 1) { Fail 'expected exactly one public key line' } # Fingerprint via ssh-keygen -lf (also validates the key). $tmpKey = New-TemporaryFile Set-Content -Path $tmpKey -Value $Pubkey -Encoding Ascii $Fingerprint = & $SshKeygen -lf $tmpKey.FullName 2>$null $keygenOk = ($LASTEXITCODE -eq 0) Remove-Item $tmpKey -Force if (-not $keygenOk) { Fail 'not a valid SSH public key' } $KeyParts = $Pubkey -split '\s+' if ($KeyParts[0] -ne 'ssh-ed25519') { Note "NOTE: key type is $($KeyParts[0]) (Ed25519 recommended)." } $KeyBlob = $KeyParts[1] # Per-key options: 'restrict' disables all forwarding/tunneling/X11, # 'pty' re-allows an interactive terminal on top of it. $KeyOpts = '' if (-not $NoRestrict) { $KeyOpts = 'restrict,pty' if ($From) { $KeyOpts += ',from="' + $From + '"' } } elseif ($From) { $KeyOpts = 'from="' + $From + '"' } $AkLine = if ($KeyOpts) { "$KeyOpts $Pubkey" } else { $Pubkey } $SshDir = Join-Path $env:ProgramData 'ssh' $SshdConfig = Join-Path $SshDir 'sshd_config' if (-not (Test-Path $SshdConfig)) { Fail "sshd_config not found at $SshdConfig - start the sshd service once (Start-Service sshd) to create it." } $KeyDir = Join-Path $SshDir "ssh-onboard\$User" $AkFile = Join-Path $KeyDir 'authorized_keys' $AkToken = "__PROGRAMDATA__/ssh/ssh-onboard/$User/authorized_keys" $BeginMark = "# >>> ssh-onboard:$User >>>" $EndMark = "# <<< ssh-onboard:$User <<<" $MatchLines = @( $BeginMark, "# key-only policy for the dedicated account '$User'. Applies ONLY to this", '# user. Remove this block (between the markers) and restart sshd to undo.', "Match User $User", " AuthorizedKeysFile $AkToken", ' PasswordAuthentication no', ' KbdInteractiveAuthentication no', ' AuthenticationMethods publickey', ' X11Forwarding no', ' AllowAgentForwarding no', ' AllowTcpForwarding no', ' PermitTunnel no', $EndMark ) # Administrators group, language-independent (German: 'Administratoren'). $AdminsGroup = (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')).Translate([System.Security.Principal.NTAccount]).Value.Split('\')[-1] $UserExists = [bool](Get-LocalUser -Name $User -ErrorAction SilentlyContinue) $KeyPresent = (Test-Path $AkFile) -and ((Get-Content -Raw -Path $AkFile) -match [regex]::Escape($KeyBlob)) $ConfigLines = Get-Content -Path $SshdConfig $BlockCurrent = @() $inBlock = $false foreach ($line in $ConfigLines) { if ($line -eq $BeginMark) { $inBlock = $true } if ($inBlock) { $BlockCurrent += $line } if ($line -eq $EndMark) { $inBlock = $false } } $BlockUpToDate = (@($BlockCurrent) -join "`n") -eq ($MatchLines -join "`n") # -- Plan --------------------------------------------------------------------- $mode = if ($Apply) { 'APPLY' } else { 'DRY RUN (nothing will change)' } Note "ssh-onboard.ps1 v$Version - $mode" Note '' Note 'Key to install:' Note " $Fingerprint" Note " owner: $KeyOwner" Note ' Make sure this fingerprint MATCHES the one published by the key owner.' Note '' Note 'Plan:' if ($UserExists) { Note " [1] user '$User' exists - reuse (account itself is not modified)" } else { Note " [1] create local user '$User' (random discarded password - key-only account)" } if ($KeyPresent) { Note " [2] key already present in $AkFile - nothing to add (ACL still enforced)" } else { Note " [2] write to ${AkFile}:" Note " $AkLine" Note ' (directory ACL restricted to SYSTEM + Administrators)' } if ($Admin) { Note " [3] add '$User' to the local $AdminsGroup group - the key still works for" Note ' this account ONLY (per-user AuthorizedKeysFile, not the shared' Note ' administrators_authorized_keys)' } else { Note " [3] no admin rights (pass -Admin if the external party needs them)" } if ($BlockUpToDate) { Note " [4] Match block in sshd_config already up to date - no service restart" } else { Note " [4] append marked Match block for '$User' to sshd_config (key-only," Note ' no forwarding), validate with sshd -t, then RESTART the sshd' Note ' service - existing SSH sessions drop for a moment' } Note '' if (-not $Apply) { Note 'Dry run only. To execute, run from an administrator PowerShell:' Note ' powershell -ExecutionPolicy Bypass -File .\ssh-onboard.ps1 -Apply' Done 0 } # -- Confirmation ------------------------------------------------------------- if (-not $Yes) { if ([Console]::IsInputRedirected) { Fail 'input is redirected - verify the fingerprint, then re-run with -Yes' } $answer = Read-Host 'Fingerprint matches the published one? Install now? [y/N]' if ($answer -notmatch '^[Yy]$') { Fail 'aborted - nothing was changed.' } } # -- Apply -------------------------------------------------------------------- if (-not $UserExists) { # Random discarded password, built directly as SecureString (never printed, # never stored; SSH password auth is denied by the Match block anyway). $bytes = New-Object byte[] 32 (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes) $secret = New-Object System.Security.SecureString foreach ($b in $bytes) { $secret.AppendChar([char](33 + ($b % 94))) } $secret.MakeReadOnly() New-LocalUser -Name $User -Password $secret -PasswordNeverExpires -AccountNeverExpires ` -Description 'ssh-onboard: external SSH access (key-only)' | Out-Null Note "created user '$User' (random discarded password)." } if ($Admin -and -not (Get-LocalGroupMember -Group $AdminsGroup -Member $User -ErrorAction SilentlyContinue)) { Add-LocalGroupMember -Group $AdminsGroup -Member $User Note "added '$User' to $AdminsGroup." } New-Item -ItemType Directory -Force -Path $KeyDir | Out-Null if ($KeyPresent) { Note "key already in $AkFile - unchanged." } else { Add-Content -Path $AkFile -Value $AkLine -Encoding Ascii Note "key installed into $AkFile." } & icacls $KeyDir /inheritance:r /grant '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null if ($LASTEXITCODE -ne 0) { Fail "icacls failed to set the ACL on $KeyDir" } if (-not $BlockUpToDate) { $backup = "$SshdConfig.ssh-onboard.bak" Copy-Item $SshdConfig $backup -Force $kept = @() $inBlock = $false foreach ($line in $ConfigLines) { if ($line -eq $BeginMark) { $inBlock = $true; continue } if ($line -eq $EndMark) { $inBlock = $false; continue } if (-not $inBlock) { $kept += $line } } Set-Content -Path $SshdConfig -Value ($kept + '' + $MatchLines) -Encoding Ascii & $SshdExe -t 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { Copy-Item $backup $SshdConfig -Force Fail 'sshd -t rejected the configuration - previous sshd_config restored, service untouched.' } Restart-Service sshd Note 'Match block written and sshd restarted.' } # -- Summary ------------------------------------------------------------------ $portMatch = $ConfigLines | Where-Object { $_ -match '^\s*Port\s+(\d+)' } | Select-Object -First 1 $port = if ($portMatch -match '^\s*Port\s+(\d+)') { $Matches[1] } else { '22' } $ip = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | Select-Object -First 1).IPAddress Note '' Note 'SUCCESS: the dedicated account is ready.' Note " user: $User port: $port local address: $(if ($ip) { $ip } else { 'unknown' })" if ($From) { Note " (access restricted to source: $From)" } Note " NOTE: 'local address' is how this system sees itself. Whether it is" Note ' reachable from outside (NAT, firewall, port forwarding) depends on' Note ' your network - this script does not change any of that.' Note '' Note 'Undo everything:' Note " Remove-LocalUser $User" Note " Remove-Item -Recurse '$KeyDir'" Note " open $SshdConfig, delete the block between" Note " '$BeginMark' and '$EndMark', then: Restart-Service sshd" Done 0