Skip to content

Instantly share code, notes, and snippets.

@InTEGr8or
Last active April 19, 2025 14:43
Show Gist options
  • Save InTEGr8or/ac2c963950163102d131f748845c5082 to your computer and use it in GitHub Desktop.
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
#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