Last active
April 19, 2025 14:43
-
-
Save InTEGr8or/ac2c963950163102d131f748845c5082 to your computer and use it in GitHub Desktop.
Add, List, and Remove SSH keys to authorized_keys while respecting Windows strict security
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#requires -Version 5.1+ | |
<# | |
Windows OpenSSH has strict permissions requirements on the `~/.ssh/authorized_keys` file that | |
can make adding or removing or even listing keys cumbersome. | |
This script handles the grunt-work to handle those requirements. | |
#> | |
<# | |
.SYNOPSIS | |
Lists SSH public keys stored in the user's authorized_keys file. | |
.DESCRIPTION | |
Reads the authorized_keys file (~/.ssh/authorized_keys) and displays | |
an identifier for each key found (comment if available, otherwise type and start of key). | |
.EXAMPLE | |
Get-SshKeys | |
# Lists all keys with their identifiers. | |
#> | |
function Get-SshKeys { | |
$authorizedKeysPath = Join-Path $env:USERPROFILE ".ssh\authorized_keys" | |
if (-not (Test-Path $authorizedKeysPath)) { | |
Write-Warning "Authorized keys file not found at: $authorizedKeysPath" | |
return | |
} | |
try { | |
$keys = Get-Content -Path $authorizedKeysPath -Encoding UTF8 -ErrorAction Stop | |
if ($null -eq $keys -or $keys.Length -eq 0) { | |
Write-Host "No keys found in $authorizedKeysPath" | |
return | |
} | |
Write-Host "Keys found in $authorizedKeysPath`:" | |
Write-Host "-------------------------------------" | |
for ($i = 0; $i -lt $keys.Count; $i++) { | |
$key = $keys[$i].Trim() | |
if ($key -eq '' -or $key.StartsWith('#')) { # Skip empty lines or full-line comments | |
continue | |
} | |
# Try to extract comment (part after the last space) | |
$parts = $key -split '\s+' | |
$identifier = $null | |
if ($parts.Count -ge 3) { | |
# Check if the last part looks like a comment (not a key type or option) | |
if ($parts[-1] -notmatch '^(ssh-|ecdsa-|sk-|AAA|[-A-Za-z0-9+/=]+={0,2}$)') { | |
$identifier = "[Comment: $($parts[-1])]" | |
} | |
} | |
# Fallback to key type and start of key blob if no comment found | |
if ($null -eq $identifier) { | |
$keyBlobStart = if($parts.count -ge 2) { $parts[1].Substring(0, [System.Math]::Min($parts[1].Length, 15)) } else { "InvalidFormat" } | |
$identifier = "[Type: $($parts[0]), Start: $keyBlobStart...]" | |
} | |
Write-Host ("{0}. {1}" -f ($i+1), $identifier) | |
} | |
Write-Host "-------------------------------------" | |
} catch { | |
Write-Error "Error reading authorized keys file: $($_.Exception.Message)" | |
} | |
} | |
<# | |
.SYNOPSIS | |
Adds a new SSH public key to the user's authorized_keys file. | |
.DESCRIPTION | |
Appends a provided SSH public key string to the authorized_keys file (~/.ssh/authorized_keys). | |
It checks if the key already exists before adding. | |
.PARAMETER PublicKeyString | |
The full SSH public key string (e.g., "ssh-rsa AAAAB3N... user@host"). This parameter is mandatory. | |
.EXAMPLE | |
Add-SshKey -PublicKeyString "ssh-ed25519 AAAAC3Nz... [email protected]" | |
# Adds the specified Ed25519 key to the file. | |
"ssh-rsa AAAAB3N... another_user@host" | Add-SshKey | |
# Adds the specified RSA key via pipeline input. | |
#> | |
function Add-SshKey { | |
[CmdletBinding()] | |
param( | |
[Parameter(Mandatory=$true, ValueFromPipeline=$true)] | |
[string]$PublicKeyString | |
) | |
$sshDir = Join-Path $env:USERPROFILE ".ssh" | |
$authorizedKeysPath = Join-Path $sshDir "authorized_keys" | |
# Ensure .ssh directory exists | |
if (-not (Test-Path $sshDir -PathType Container)) { | |
try { | |
New-Item -Path $sshDir -ItemType Directory -Force -ErrorAction Stop | Out-Null | |
Write-Verbose "Created directory: $sshDir" | |
} catch { | |
Write-Error "Failed to create directory '$sshDir': $($_.Exception.Message)" | |
return | |
} | |
} | |
$keyToAdd = $PublicKeyString.Trim() | |
# Basic validation | |
if ($keyToAdd -eq '' -or -not ($keyToAdd -match '^(ssh-|ecdsa-|sk-)')) { | |
Write-Error "Invalid public key string provided." | |
return | |
} | |
# Check if file exists and key already present | |
$keyExists = $false | |
if (Test-Path $authorizedKeysPath) { | |
try { | |
# Read content, be mindful of potential file locks if sshd is actively reading | |
$currentKeys = Get-Content -Path $authorizedKeysPath -Encoding UTF8 -ErrorAction SilentlyContinue # Read silently first | |
if ($null -ne $currentKeys) { | |
if ($currentKeys -contains $keyToAdd) { | |
$keyExists = $true | |
} | |
} else { | |
# If Get-Content failed silently, maybe retry with error action stop to see why | |
# Or assume file is empty/unreadable for now | |
Write-Verbose "Could not read existing keys or file is empty." | |
} | |
} catch { | |
Write-Warning "Could not read existing keys file to check for duplicates: $($_.Exception.Message)" | |
# Decide if you want to proceed anyway or stop. Let's proceed but warn. | |
} | |
} | |
if ($keyExists) { | |
Write-Warning "The provided key already exists in '$authorizedKeysPath'." | |
return | |
} | |
# Add the key | |
try { | |
# Use Add-Content which handles file creation and appending correctly | |
Add-Content -Path $authorizedKeysPath -Value $keyToAdd -Encoding UTF8 -ErrorAction Stop | |
Write-Host "Successfully added key to '$authorizedKeysPath'." | |
# Optional: Add a newline if the file previously existed but was empty or had no trailing newline | |
# Add-Content usually handles this well, but explicit check could be added if needed. | |
} catch { | |
Write-Error "Failed to add key to '$authorizedKeysPath': $($_.Exception.Message)" | |
} | |
} | |
<# | |
.SYNOPSIS | |
Removes an SSH public key from the user's authorized_keys file based on a unique identifier. | |
.DESCRIPTION | |
Searches the authorized_keys file (~/.ssh/authorized_keys) for a key matching the provided | |
identifier (comment or start of key blob) and removes the matching line(s). | |
.PARAMETER Identifier | |
A string uniquely identifying the key to remove. This can be the key's comment | |
(e.g., "user@host") or the first part of the key (e.g., "ssh-rsa AAAAB3NzaC1yc"). | |
The match is case-sensitive for the key part, potentially case-insensitive for comments depending on usage. | |
Use Get-SshKeys to see available identifiers. Mandatory parameter. | |
.PARAMETER Force | |
If multiple keys match the identifier, Force must be specified to remove all of them. | |
Otherwise, an error is shown. | |
.EXAMPLE | |
Remove-SshKey -Identifier "[email protected]" | |
# Removes the key with the comment "[email protected]". | |
.EXAMPLE | |
Remove-SshKey -Identifier "ssh-rsa AAAAB3N" | |
# Removes the key(s) starting with "ssh-rsa AAAAB3N". Use with caution. | |
.EXAMPLE | |
Remove-SshKey -Identifier "ambiguous_comment" -Force | |
# Removes all keys matching the identifier "ambiguous_comment". | |
#> | |
function Remove-SshKey { | |
[CmdletBinding(SupportsShouldProcess=$true)] | |
param( | |
[Parameter(Mandatory=$true)] | |
[string]$Identifier, | |
[Parameter()] | |
[switch]$Force | |
) | |
$authorizedKeysPath = Join-Path $env:USERPROFILE ".ssh\authorized_keys" | |
if (-not (Test-Path $authorizedKeysPath)) { | |
Write-Warning "Authorized keys file not found at: $authorizedKeysPath" | |
return | |
} | |
try { | |
$allKeys = Get-Content -Path $authorizedKeysPath -Encoding UTF8 -ErrorAction Stop | |
$keysToRemove = @() | |
$keysToKeep = @() | |
$foundIndices = @() | |
for ($i = 0; $i -lt $allKeys.Count; $i++) { | |
$key = $allKeys[$i].Trim() | |
if ($key -eq '' -or $key.StartsWith('#')) { | |
$keysToKeep += $allKeys[$i] # Preserve empty lines/full comments if desired, or filter them too | |
continue | |
} | |
# Check if identifier matches the comment OR the start of the key string | |
$isMatch = $false | |
$parts = $key -split '\s+' | |
if ($parts.Count -ge 3) { | |
# Check comment (part after blob) | |
if ($parts[-1] -notmatch '^(ssh-|ecdsa-|sk-|AAA|[-A-Za-z0-9+/=]+={0,2}$)' -and $parts[-1] -eq $Identifier) { | |
$isMatch = $true | |
} | |
# Could add case-insensitive comment match: | |
# if ($parts[-1] -notmatch '...' -and $parts[-1].Equals($Identifier, [System.StringComparison]::OrdinalIgnoreCase)) { $isMatch = $true } | |
} | |
# Check start of key string (case-sensitive) if not already matched by comment | |
if (-not $isMatch -and $key.StartsWith($Identifier)) { | |
$isMatch = $true | |
} | |
if ($isMatch) { | |
$keysToRemove += $key | |
$foundIndices += $i | |
} else { | |
$keysToKeep += $allKeys[$i] | |
} | |
} | |
if ($keysToRemove.Count -eq 0) { | |
Write-Warning "No keys found matching identifier: '$Identifier'" | |
return | |
} | |
if ($keysToRemove.Count -gt 1 -and -not $Force) { | |
Write-Error "Multiple keys found matching identifier '$Identifier'. Use -Force to remove all." | |
Write-Host "Matching keys found at lines: $(($foundIndices | ForEach-Object { $_ + 1 }) -join ', ')" | |
return | |
} | |
if ($PSCmdlet.ShouldProcess($authorizedKeysPath, ("Remove {0} key(s) matching '{1}'" -f $keysToRemove.Count, $Identifier))) { | |
try { | |
# Overwrite the file with the filtered content | |
Set-Content -Path $authorizedKeysPath -Value $keysToKeep -Encoding UTF8 -Force -ErrorAction Stop | |
Write-Host ("Successfully removed {0} key(s) matching identifier '{1}' from '{2}'." -f $keysToRemove.Count, $Identifier, $authorizedKeysPath) | |
} catch { | |
Write-Error "Failed to write updated keys to '$authorizedKeysPath': $($_.Exception.Message)" | |
} | |
} | |
} catch { | |
Write-Error "Error processing authorized keys file: $($_.Exception.Message)" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment