Public/Import-Fido2SshKey.ps1
|
function Import-Fido2SshKey { <# .SYNOPSIS Imports resident FIDO2 SSH keys from a connected authenticator into the local SSH directory. .DESCRIPTION Runs `ssh-keygen -K` to extract every resident SSH key from a connected FIDO2 authenticator (YubiKey or other passkey provider) and installs them into `-SshDirectory` (default `%USERPROFILE%\.ssh`) using the same canonical filename layout that `New-Fido2SshKey` produces: id_<keytype>_sk_rk[_<label>]_<thumbprint> Because the thumbprint is derived from the public key fingerprint, a credential extracted here lands at the exact same filename as if it had been created by `New-Fido2SshKey`, so re-running this cmdlet does not produce duplicate files, duplicate `ssh-agent` entries, or duplicate selection prompts in the publish cmdlets. Optionally loads the private keys into `ssh-agent`. .PARAMETER SshDirectory Destination folder. Defaults to `%USERPROFILE%\.ssh`. .PARAMETER Force Overwrite existing key files with the same name. .PARAMETER SkipAgent Don't start `ssh-agent` and don't run `ssh-add`. .EXAMPLE Import-Fido2SshKey .EXAMPLE Import-Fido2SshKey -SshDirectory C:\keys -Force -SkipAgent #> [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$SshDirectory = (Join-Path $env:USERPROFILE ".ssh"), [switch]$Force, [switch]$SkipAgent ) if (-not (Get-Command ssh-keygen -ErrorAction SilentlyContinue)) { throw "ssh-keygen was not found. Install the OpenSSH Client Windows feature first." } if (-not (Test-Path -LiteralPath $SshDirectory)) { New-Item -ItemType Directory -Path $SshDirectory | Out-Null } $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("fido2-ssh-import-" + [System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $tempRoot | Out-Null $installedPrivateKeys = @() $skippedFiles = @() try { Push-Location $tempRoot Write-Host "Touch your authenticator if prompted. Downloading resident FIDO2 SSH keys..." # `-q` silences ssh-keygen's per-key "Saved..." chatter while # keeping the FIDO2 PIN prompt (which is written to stdout on # Windows) visible to the user. & ssh-keygen -q -K -N '' if ($LASTEXITCODE -ne 0) { throw "ssh-keygen -K failed with exit code $LASTEXITCODE." } $extracted = @(Get-ChildItem -Path $tempRoot -File) $extractedPubs = @($extracted | Where-Object { $_.Extension -eq ".pub" }) if ($extractedPubs.Count -eq 0) { throw "No resident FIDO2 SSH keys were extracted from the authenticator." } # Build a fingerprint -> path map of every FIDO2 SSH key that's # already in $SshDirectory. We dedupe by SHA256 fingerprint, not # filename, because OpenSSH's ssh-keygen -K names extracted files # using the credential's application *and* user_id (a long hex # blob), while New-Fido2SshKey produces a shorter canonical name # without the user_id. Filename-only dedupe would therefore miss # the match and re-install the same credential under a second # filename. $existingFingerprints = @{} foreach ($existingPub in (Get-ChildItem -Path $SshDirectory -File -Filter "id_*_sk_rk*.pub" -ErrorAction SilentlyContinue)) { try { $existingFingerprints[(Get-Fido2KeyFingerprint -PublicKeyPath $existingPub.FullName)] = $existingPub.FullName } catch { Write-Verbose "Skipping unreadable existing key $($existingPub.FullName): $_" } } # ssh-keygen -K names files as `id_<keytype>_sk_rk_<application>`, # optionally followed by `_<user_id_hex>` (typically 32-64 hex # chars) on recent OpenSSH releases. We strip that trailing hex # blob so the on-disk label matches the one New-Fido2SshKey would # have used, then re-derive the canonical `_<thumbprint>` form. foreach ($pub in $extractedPubs) { $extractedBase = [System.IO.Path]::GetFileNameWithoutExtension($pub.Name) $extractedPriv = Join-Path $tempRoot $extractedBase if (-not (Test-Path -LiteralPath $extractedPriv)) { Write-Warning "Public key $($pub.Name) had no matching private key file in ssh-keygen output. Skipping." continue } $fingerprint = Get-Fido2KeyFingerprint -PublicKeyPath $pub.FullName if ($existingFingerprints.ContainsKey($fingerprint) -and -not $Force) { Write-Verbose "Skipping $($pub.Name): same credential is already installed as $($existingFingerprints[$fingerprint])." $skippedFiles += $existingFingerprints[$fingerprint] continue } # Map ssh-keygen's `id_<typeSuffix>_rk[_<label>[_<user_id_hex>]]` # layout back to KeyType + Label so the shared canonical-name # helper can rebuild the filename the rest of the module expects. if ($extractedBase -notmatch '^id_(?<typeSuffix>ed25519_sk|ecdsa_sk)_rk(?:_(?<label>.+))?$') { Write-Warning "Extracted file $($pub.Name) does not match the expected `id_<keytype>_sk_rk...` layout. Installing verbatim." $destPub = Join-Path $SshDirectory $pub.Name $destPriv = Join-Path $SshDirectory $extractedBase if ((Test-Path -LiteralPath $destPriv) -and -not $Force) { $skippedFiles += $destPriv continue } if ($PSCmdlet.ShouldProcess($destPriv, "Install extracted key file")) { Move-Item -LiteralPath $extractedPriv -Destination $destPriv -Force:$Force Move-Item -LiteralPath $pub.FullName -Destination $destPub -Force:$Force $installedPrivateKeys += $destPriv $existingFingerprints[$fingerprint] = $destPriv } continue } $keyType = switch ($matches['typeSuffix']) { 'ed25519_sk' { 'ed25519-sk' } 'ecdsa_sk' { 'ecdsa-sk' } } $label = $matches['label'] # Strip a trailing `_<hex>` segment (OpenSSH appends the FIDO # user_id as hex when extracting). Anything that ends in `_` # followed by at least 16 hex characters is treated as the # user_id and removed so the canonical filename matches what # New-Fido2SshKey produced for the same credential. if ($label -match '^(?<clean>.+?)_[0-9a-fA-F]{16,}$') { $label = $matches['clean'] } $thumbprint = Get-Fido2KeyThumbprint -PublicKeyPath $pub.FullName $canonical = Get-Fido2CanonicalName -KeyType $keyType -Label $label -Thumbprint $thumbprint $destPrivate = Join-Path $SshDirectory $canonical $destPublic = "$destPrivate.pub" if ((Test-Path -LiteralPath $destPrivate) -and -not $Force) { Write-Verbose "Skipping existing file: $destPrivate. Same credential is already installed; re-run with -Force to overwrite." $skippedFiles += $destPrivate continue } if ($PSCmdlet.ShouldProcess($destPrivate, "Install extracted key as canonical filename")) { Move-Item -LiteralPath $extractedPriv -Destination $destPrivate -Force:$Force Move-Item -LiteralPath $pub.FullName -Destination $destPublic -Force:$Force $installedPrivateKeys += $destPrivate $existingFingerprints[$fingerprint] = $destPrivate } } } finally { Pop-Location Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue } if (-not $SkipAgent) { $service = Get-Service -Name ssh-agent -ErrorAction SilentlyContinue if (-not $service) { Write-Warning "ssh-agent service is not available on this machine. Keys installed to disk only." } else { if ($service.StartType -eq "Disabled") { try { Set-Service -Name ssh-agent -StartupType Manual } catch { Write-Warning "Unable to enable ssh-agent startup type. Run from an elevated session for agent loading." } } if ((Get-Service ssh-agent).Status -ne "Running") { try { Start-Service ssh-agent } catch { Write-Warning "Unable to start ssh-agent. Run from an elevated session for agent loading." } } if (-not (Get-Command ssh-add -ErrorAction SilentlyContinue)) { Write-Warning "ssh-add was not found. Keys installed to disk but not loaded into ssh-agent." } else { foreach ($keyPath in $installedPrivateKeys) { Write-Host "Adding $keyPath to ssh-agent..." & ssh-add $keyPath if ($LASTEXITCODE -ne 0) { Write-Warning "ssh-add failed for $keyPath. The key is still installed in $SshDirectory." } } } } } Write-Host "Imported $($installedPrivateKeys.Count) resident FIDO2 SSH key(s) to $SshDirectory." if ($skippedFiles.Count -gt 0) { Write-Host "Skipped $($skippedFiles.Count) existing file(s). Re-run with -Force to overwrite them." } Write-Host "Use the corresponding .pub file(s) from $SshDirectory on remote hosts." } |