Public/Remove-Fido2SshKey.ps1
|
function Remove-Fido2SshKey { <# .SYNOPSIS Removes FIDO2 SSH key files from the SSH directory and unloads them from `ssh-agent`. .DESCRIPTION Cleans up keys produced by `New-Fido2SshKey` / `Import-Fido2SshKey`. By default only **resident** key file pairs (`id_*_sk_rk*`) are targeted. Non-resident (software) passkeys are intentionally skipped because their private key handle file is the only copy of the credential — deleting it means the key is permanently lost and cannot be recovered from the authenticator. Pass `-IncludeNonResident` to include them. The resident credential on the FIDO2 authenticator itself is NOT touched by this cmdlet — use `ssh-keygen -K` followed by FIDO management tools (e.g. `ykman fido credentials delete`) for that. Scope can be narrowed with `-PublicKeyPath` (a specific key) or `-Label` (a substring match against the label segment of the canonical filename). When neither is supplied, all targeted key types in the directory are processed. .PARAMETER PublicKeyPath Full path to a specific `*.pub` file to remove. The matching private key (same name without `.pub`) is removed along with it. .PARAMETER Label Case-insensitive substring filter against the label segment of the canonical filename. Only keys whose label contains this value are removed. .PARAMETER SshDirectory Source folder. Defaults to `%USERPROFILE%\.ssh` on Windows and `$HOME/.ssh` on Linux/macOS. .PARAMETER IncludeNonResident Also remove non-resident (software) passkey file pairs. Use with care: the private key handle file is the only copy of the credential. After removal you will also want to delete the corresponding passkey from the authenticator's credential store (e.g. via Windows Hello settings, your browser's passkey manager, or `ykman fido credentials delete`). .PARAMETER SkipAgent Don't touch `ssh-agent`. Files on disk are still removed. .PARAMETER Force Skip the per-key confirmation prompt. .EXAMPLE Remove-Fido2SshKey Lists every FIDO2 resident key in `~/.ssh`, prompts for confirmation, unloads each from `ssh-agent` and deletes both file halves. Non-resident (software) passkeys are printed but skipped. .EXAMPLE Remove-Fido2SshKey -Label work-laptop -Force Removes any FIDO2 resident key whose canonical filename contains `work-laptop` without prompting. .EXAMPLE Remove-Fido2SshKey -IncludeNonResident -Label work-laptop -Force Removes both the resident key AND the non-resident (software) passkey that match `work-laptop`. Remember to also clean up the passkey from the authenticator's credential store. .EXAMPLE Remove-Fido2SshKey -PublicKeyPath C:\Users\me\.ssh\id_ed25519_sk_rk_pin_abc123def456.pub #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'All')] param( [Parameter(Mandatory = $true, ParameterSetName = 'ByPath')] [string]$PublicKeyPath, [Parameter(ParameterSetName = 'All')] [string]$Label, [string]$SshDirectory = (Get-Fido2DefaultSshDirectory), [switch]$IncludeNonResident, [switch]$SkipAgent, [switch]$Force ) # -Force suppresses the High-impact confirmation prompt while still # honouring an explicit -Confirm or -WhatIf from the caller. if ($Force -and -not $PSBoundParameters.ContainsKey('Confirm')) { $ConfirmPreference = 'None' } if (-not (Test-Path -LiteralPath $SshDirectory)) { Write-Verbose "SSH directory not found: $SshDirectory. Nothing to remove." return } # Resolve the set of public-key files we'll process. $targets = @() # keys to remove $nrSkipped = @() # non-resident keys that were found but skipped if ($PSCmdlet.ParameterSetName -eq 'ByPath') { if (-not (Test-Path -LiteralPath $PublicKeyPath)) { throw "Public key file not found: $PublicKeyPath" } $pub = Get-Item -LiteralPath $PublicKeyPath $isNonResident = ($pub.Name -notmatch '_rk') if ($isNonResident -and -not $IncludeNonResident) { Write-Host "Skipped non-resident (software) passkey: $($pub.FullName)" Write-Host " Non-resident keys are not removed by default. Re-run with -IncludeNonResident to delete it." Write-Host " WARNING: deleting the private key handle file permanently destroys the credential." return } $targets = @($pub) } else { # Collect resident candidates. $residentCandidates = @(Get-ChildItem -Path $SshDirectory -File -Filter "id_*_sk_rk*.pub" -ErrorAction SilentlyContinue) # Collect non-resident candidates (id_*_sk_*.pub without _rk). $allSkPubs = @(Get-ChildItem -Path $SshDirectory -File -Filter "id_*_sk_*.pub" -ErrorAction SilentlyContinue) $nrCandidates = @($allSkPubs | Where-Object { $_.Name -notmatch '_rk' }) $applyLabelFilter = { param($candidates) if (-not $Label) { return $candidates } return @($candidates | Where-Object { $base = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) if ($base -match '^id_(?:ed25519_sk|ecdsa_sk)_(?:rk_)?(?<rest>.+)$') { $rest = $matches['rest'] if ($rest -match '^(?<label>.+)_[0-9a-f]{12}$') { return $matches['label'] -like "*$Label*" } } return $false }) } $residentCandidates = & $applyLabelFilter $residentCandidates $nrCandidates = & $applyLabelFilter $nrCandidates $targets = $residentCandidates if ($nrCandidates.Count -gt 0) { if ($IncludeNonResident) { $targets = @($targets) + @($nrCandidates) } else { $nrSkipped = $nrCandidates } } } if ($targets.Count -eq 0 -and $nrSkipped.Count -eq 0) { Write-Host "No matching FIDO2 SSH keys found in $SshDirectory." return } if ($targets.Count -eq 0) { Write-Host "No resident FIDO2 SSH keys matched. $($nrSkipped.Count) non-resident (software) passkey(s) were found but skipped (see below)." } # ssh-agent state — start the service only if it is already loadable; we # don't want a cleanup cmdlet to spin up services unnecessarily. $useAgent = -not $SkipAgent -and [bool](Get-Command ssh-add -ErrorAction SilentlyContinue) if ($useAgent) { $isWindowsHost = if (Get-Variable -Name IsWindows -Scope Global -ErrorAction SilentlyContinue) { [bool]$IsWindows } else { $true } if ($isWindowsHost) { $agentService = Get-Service -Name ssh-agent -ErrorAction SilentlyContinue if ($agentService -and $agentService.Status -ne 'Running') { try { Start-Service ssh-agent -ErrorAction Stop } catch { Write-Verbose "Could not start ssh-agent ($_). Skipping agent removal." $useAgent = $false } } } else { # On Linux/macOS the user owns ssh-agent lifecycle. If no agent # socket is exposed, ssh-add can't do anything useful; skip. if ([string]::IsNullOrWhiteSpace($env:SSH_AUTH_SOCK)) { Write-Verbose 'SSH_AUTH_SOCK is not set. Skipping ssh-agent removal; files on disk will still be removed.' $useAgent = $false } } } $removedCount = 0 $removedNrCount = 0 foreach ($pub in $targets) { $pubPath = $pub.FullName $privPath = $pubPath -replace '\.pub$', '' $isNonResident = ($pub.Name -notmatch '_rk') $target = if (Test-Path -LiteralPath $privPath) { $privPath } else { $pubPath } $action = "Remove FIDO2 SSH key (and ssh-agent entry)" if (-not $PSCmdlet.ShouldProcess($target, $action)) { continue } if ($useAgent -and (Test-Path -LiteralPath $privPath)) { # `ssh-add -d` writes a one-line success/failure message to stderr # which is noisy when the identity wasn't loaded. Swallow the # exit code; the file removal below is the authoritative cleanup. & ssh-add -d $privPath 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { Write-Verbose "Unloaded $privPath from ssh-agent." } else { Write-Verbose "ssh-add -d returned $LASTEXITCODE for $privPath (likely not loaded)." } } foreach ($file in @($privPath, $pubPath)) { if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Force -ErrorAction Stop Write-Verbose "Deleted $file." } } Write-Host "Removed $target" $removedCount++ if ($isNonResident) { $removedNrCount++ } } Write-Host "" Write-Host "Removed $removedCount FIDO2 SSH key file pair(s) from $SshDirectory." if ($SkipAgent) { Write-Host "ssh-agent was not modified (-SkipAgent)." } if ($removedNrCount -gt 0) { Write-Host "" Write-Host " IMPORTANT: $removedNrCount non-resident (software) passkey(s) were deleted." Write-Host " The private key handle is gone and cannot be recovered from the authenticator." Write-Host " You should also remove the corresponding passkey from the authenticator's" Write-Host " credential store to keep it clean:" Write-Host " - Windows Hello / Microsoft Authenticator: Settings > Accounts > Passkeys" Write-Host " - YubiKey: ykman fido credentials list / delete" Write-Host " - Other: use your authenticator's management tool or browser passkey settings." } if ($nrSkipped.Count -gt 0) { Write-Host "" Write-Host " Skipped $($nrSkipped.Count) non-resident (software) passkey(s) — they are NOT removed by" Write-Host " default because the private key handle cannot be recovered if deleted." Write-Host " Re-run with -IncludeNonResident to also remove them." foreach ($skipped in $nrSkipped) { Write-Host " $($skipped.FullName)" } } if ($removedNrCount -eq 0 -and $nrSkipped.Count -eq 0) { Write-Host "Note: the resident credential on the authenticator itself is unchanged." } } |