Public/New-Fido2SshKey.ps1

function New-Fido2SshKey {
    <#
    .SYNOPSIS
        Generates a FIDO2 SSH key on a connected authenticator.
 
    .DESCRIPTION
        Prompts for an e-mail (used as the key comment) and a label
        (used in the FIDO2 application string), then runs `ssh-keygen`
        to create a new Security Key credential on the authenticator
        (YubiKey or other passkey provider).
 
        By default the credential is **resident** — stored on the
        authenticator and recoverable with `Import-Fido2SshKey`. The
        resulting files follow the canonical resident layout:
 
            id_<keytype>_sk_rk_<label>_<thumbprint>
            id_<keytype>_sk_rk_<label>_<thumbprint>.pub
 
        Pass `-NonResident` to create a **software passkey** instead.
        In this mode the credential is NOT stored on the authenticator;
        only the private key handle file on disk holds the credential.
        Losing that file means the key cannot be recovered. The files
        follow the non-resident layout (no `_rk` segment):
 
            id_<keytype>_sk_<label>_<thumbprint>
            id_<keytype>_sk_<label>_<thumbprint>.pub
 
        In both modes the thumbprint is a short slice of the key's
        SHA256 fingerprint, so multiple credentials with the same label
        won't collide.
 
        By default the credential is created with `-O verify-required`,
        so the authenticator will require its FIDO2 PIN (in addition to
        a touch) every time the key is used. Pass `-NoPin` to omit that
        constraint and require only a touch.
 
        The private key file is created with an empty passphrase so
        it can be loaded by `ssh-agent` and used by the publish
        cmdlets without further prompting. For resident keys the actual
        private key material stays on the authenticator; the file on
        disk is only a handle. For non-resident keys the key handle
        itself lives in that file — back it up accordingly.
 
    .PARAMETER Email
        Value placed in the public-key comment field. Prompted for
        when not supplied.
 
    .PARAMETER Label
        Short label embedded in the FIDO application string
        (`ssh:<label>`) and in the installed filename. Must only
        contain letters, digits, '.' or '-' (no underscores, since
        the filename parser uses '_' as a token boundary). Prompted
        for when not supplied.
 
    .PARAMETER SshDirectory
        Destination folder. Defaults to `%USERPROFILE%\.ssh` on Windows and
        `$HOME/.ssh` on Linux/macOS.
 
    .PARAMETER KeyType
        FIDO key algorithm. Defaults to `ed25519-sk`. Use
        `ecdsa-sk` for older authenticators that don't support
        Ed25519.
 
    .PARAMETER NonResident
        Create a non-resident (software) passkey. The credential
        handle is stored in the private key file on disk rather than
        on the authenticator. The file cannot be re-imported from the
        authenticator if lost — keep a backup.
 
    .PARAMETER NoPin
        Omit the default `-O verify-required` constraint so the
        authenticator only requires a touch (no FIDO2 PIN) when the
        key is used.
 
    .PARAMETER Force
        Overwrite an existing key file with the same name.
 
    .PARAMETER SkipAgent
        Don't try to add the new private key to `ssh-agent`.
 
    .EXAMPLE
        New-Fido2SshKey
 
        Prompts for e-mail and label, generates a PIN-protected
        resident Ed25519 FIDO2 SSH key on the authenticator, then
        installs it into the user's .ssh directory.
 
    .EXAMPLE
        New-Fido2SshKey -Email me@example.com -Label work-laptop -NoPin
 
        Generates a touch-only resident credential with application
        `ssh:work-laptop` and installs it as
        ~/.ssh/id_ed25519_sk_rk_work-laptop_<thumbprint>(.pub).
 
    .EXAMPLE
        New-Fido2SshKey -Email me@example.com -Label work-laptop -NonResident
 
        Generates a PIN-protected non-resident (software) passkey.
        No resident credential is stored on the authenticator. Key
        installed as ~/.ssh/id_ed25519_sk_work-laptop_<thumbprint>(.pub).
        Keep the private key file backed up — it cannot be re-imported
        from the authenticator.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [string]$Email,
        [string]$Label,
        [string]$SshDirectory = (Get-Fido2DefaultSshDirectory),
        [ValidateSet('ed25519-sk', 'ecdsa-sk')]
        [string]$KeyType = 'ed25519-sk',
        [switch]$NonResident,
        [switch]$NoPin,
        [switch]$Force,
        [switch]$SkipAgent
    )

    if (-not (Get-Command ssh-keygen -ErrorAction SilentlyContinue)) {
        throw "ssh-keygen was not found. Install the OpenSSH client (on Windows: the OpenSSH Client capability; on Linux/macOS: the `openssh-client` / `openssh` package)."
    }

    if ([string]::IsNullOrWhiteSpace($Email)) {
        $Email = Read-Host "E-mail (added as the SSH key comment)"
    }
    $Email = $Email.Trim()
    if ([string]::IsNullOrWhiteSpace($Email)) {
        throw "E-mail must not be empty."
    }
    if ($Email -match '["\r\n]') {
        throw "E-mail must not contain quotes or line breaks."
    }

    if ([string]::IsNullOrWhiteSpace($Label)) {
        $Label = Read-Host "Key label (used in the FIDO application string and the installed filename)"
    }
    $Label = $Label.Trim()
    if ($Label -notmatch '^[A-Za-z0-9.-]+$') {
        throw "Label may only contain letters, digits, '.' or '-' (no underscores)."
    }

    $application  = "ssh:$Label"
    $sshKeygenExe = (Get-Command ssh-keygen).Source
    $verifyOption = if ($NoPin) { '' } else { '-O verify-required ' }
    $isResident   = -not $NonResident.IsPresent

    if (-not (Test-Path -LiteralPath $SshDirectory)) {
        New-Item -ItemType Directory -Path $SshDirectory | Out-Null
    }

    # Generate the resident credential on the authenticator into a
    # temp directory; we then rename/move the produced files into
    # $SshDirectory under the canonical layout.
    $tempRoot    = Join-Path ([System.IO.Path]::GetTempPath()) ("fido2-ssh-new-" + [System.Guid]::NewGuid().ToString("N"))
    New-Item -ItemType Directory -Path $tempRoot | Out-Null
    $tempKeyPath = Join-Path $tempRoot "newkey"
    $tempPubPath = "$tempKeyPath.pub"

    try {
        $shouldProcessDesc = "Generate $( if ($isResident) { 'resident' } else { 'non-resident (software)' } ) FIDO2 SSH key ($KeyType, application=$application" + $(if ($NoPin) { ', touch-only' } else { ', PIN+touch' }) + ")"
        if (-not $PSCmdlet.ShouldProcess("authenticator", $shouldProcessDesc)) {
            return
        }

        if ($NoPin) {
            Write-Host "Touch your authenticator when it starts blinking."
        }
        else {
            Write-Host "Enter your FIDO2 PIN when prompted, then touch your authenticator when it starts blinking."
        }

        # Windows PowerShell 5.1's native-call operator drops bare empty-string
        # arguments, which would cause ssh-keygen to treat "-f" as the
        # passphrase value for `-N ""`. PowerShell 7+ preserves empty args on
        # every platform, so the cmd.exe workaround is only needed on the
        # legacy desktop edition. `-q` silences the chatty
        # "Generating public/private..." / fingerprint / randomart output
        # without hiding the FIDO2 PIN prompt.
        $useCmdShim = ($PSVersionTable.PSEdition -eq 'Desktop')

        if ($useCmdShim) {
            $residentOption = if ($isResident) { '-O resident ' } else { '' }
            $cmdLine = '"{0}" -q -t {1} {2}{3}-O "application={4}" -C "{5}" -N "" -f "{6}"' -f `
                $sshKeygenExe, $KeyType, $residentOption, $verifyOption, $application, $Email, $tempKeyPath
            Write-Verbose "ssh-keygen command (cmd.exe): $cmdLine"
            & cmd.exe /c "`"$cmdLine`""
        }
        else {
            $sshKeygenArgs = @('-q', '-t', $KeyType)
            if ($isResident) { $sshKeygenArgs += @('-O', 'resident') }
            if (-not $NoPin) { $sshKeygenArgs += @('-O', 'verify-required') }
            $sshKeygenArgs += @('-O', "application=$application", '-C', $Email, '-N', '', '-f', $tempKeyPath)
            Write-Verbose ("ssh-keygen args: " + ($sshKeygenArgs -join ' '))
            & $sshKeygenExe @sshKeygenArgs
        }
        if ($LASTEXITCODE -ne 0) {
            throw "ssh-keygen failed with exit code $LASTEXITCODE."
        }
        if (-not (Test-Path -LiteralPath $tempPubPath)) {
            throw "ssh-keygen did not produce expected public key file: $tempPubPath"
        }

        # Derive a short thumbprint from the SHA256 fingerprint so
        # multiple credentials with the same label don't collide, and
        # so that Import-Fido2SshKey can land an extracted copy of
        # this same credential at the exact same filename.
        $thumbprint  = Get-Fido2KeyThumbprint -PublicKeyPath $tempPubPath
        $finalName   = Get-Fido2CanonicalName -KeyType $KeyType -Label $Label -Thumbprint $thumbprint -Resident $isResident
        $destKeyPath = Join-Path $SshDirectory $finalName
        $destPubPath = "$destKeyPath.pub"

        foreach ($existing in @($destKeyPath, $destPubPath)) {
            if ((Test-Path -LiteralPath $existing) -and -not $Force) {
                throw "Destination already exists: $existing. Re-run with -Force to overwrite."
            }
        }

        Move-Item -LiteralPath $tempKeyPath -Destination $destKeyPath -Force:$Force
        Move-Item -LiteralPath $tempPubPath -Destination $destPubPath -Force:$Force

        Write-Host ""
        Write-Host "FIDO2 SSH key installed:"
        Write-Host " Private: $destKeyPath"
        Write-Host " Public: $destPubPath"
        if (-not $isResident) {
            Write-Host ""
            Write-Host " NOTE: This is a non-resident (software) passkey. The private key handle"
            Write-Host " is stored ONLY in the file above — not on the authenticator. If you"
            Write-Host " lose this file the key cannot be recovered. Back it up securely."
        }

        if (-not $SkipAgent -and (Get-Command ssh-add -ErrorAction SilentlyContinue)) {
            & ssh-add $destKeyPath
            if ($LASTEXITCODE -ne 0) {
                Write-Warning "ssh-add failed for $destKeyPath. The key is still installed in $SshDirectory."
            }
        }
    }
    finally {
        Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
    }
}