Skip to content

Centralized Signature and Key Management

This page provides an overview of how to implement centralized signature and key management for your WSL instances.

The Wsl Manager configured instances deploy a zsh plugin that creates a bridge between the WSL instances and the Windows host, allowing for seamless key and signature management.

The setup and initial management is a bit tedious because it involves multiple steps and configurations. However we believe that the benefits of centralized key management and seamless integration between WSL and Windows are worth the effort. There are benefits in security, usability, and maintainability.

Overview

We use gpg4win for managing GPG keys and signatures on Windows. This allows for a seamless integration between WSL and Windows, enabling the use of the same GPG keys and configurations across environments.

  • GnuPG is configured to serve as a backend for SSH authentication, allowing for the use of GPG keys for SSH connections.
  • SSH keys are added to the GPG agent.

The zsh plugin performs the following:

  1. If the wsl2-ssh-pageant.exe executable is not present, download it to C:\Users\Public\Downloads
  2. Set and export the SSH_AUTH_SOCK variable to $HOME/.ssh/agent.sock
  3. Set and export the GPG_AGENT_SOCK variable to $HOME/.gnupg/S.gpg-agent
  4. Launch the socat command listening on the respective socket. When a connection is made, it will fork wsl2-ssh-pageant.exe that will run on Windows. wsl2-ssh-pageant.exe will open a connection to the appropriate Windows named pipe (either S.gpg-agent or S.gpg-agent.ssh) and redirect the input and output. This will allow the Wsl instance gnupg and ssh programs to use the Windows agents as if they were running natively.

The following diagram shows the architecture of the solution:

graph TD;
  subgraph Linux ["🐧 Linux (WSL)"]
    A[SSH Client]
    B[GPG Client]
    C[socat SSH]
    D[socat GPG]
  end

  subgraph Windows ["🪟 Windows Host"]
    G[wsl2-ssh-pageant]
    H[wsl2-ssh-pageant]
    E[SSH Agent]
    F[GPG Agent]
  end

  A <-->|<small><tt>$HOME/.ssh/agent.sock</tt></small>| C
  B <-->|<small><tt>$HOME/.gnupg/S.gpg-agent</tt></small>| D
  C <-->|<small>WSL I/O</small>| G
  D <-->|<small>WSL I/O</small>| H
  G <--> |<small><tt>$Env:LOCALAPPDATA\gnupg\S.gpg-agent</tt></small>| E
  H <--> |<small><tt>$Env:LOCALAPPDATA\gnupg\S.gpg-agent.ssh</tt></small>| F

Windows configuration

GPG4Win Installation

First install gpg4win. If you are using scoop (recommended), issue the following command in a powershell terminal:

scoop install gpg4win

Launch the installed client GUI, Kleopatra, in order to verify the installation.

GPG Agent configuration

Please refer to the gnupg documentation. The configuration files are located in the $Env:APPDATA\gnupg directory.

The following are the relevant options for our use case:

# Keys are cached (no password requested) for 4 hours
default-cache-ttl 14400
default-cache-ttl-ssh 14400
# Even if refreshed, a key may not last in the cache for more than 8 hours
max-cache-ttl 28800
max-cache-ttl-ssh 28800

# Enable the SSH support
enable-ssh-support
# Enable putty pageant support
enable-putty-support
# Enable the Win32 OpenSSH support
enable-win32-openssh-support
# UTF-8 support for compatibility
charset utf-8
# Enable smartcard (to use Yubikey for instance)
use-agent

Restart the GPG agent

To restart the GPG agent, you can use the following command:

# kill the agent
gpgconf --kill gpg-agent
# restart the agent
gpg-connect-agent /bye

Key Management

Create a new key pair (if needed)

If you don't already have a GPG key pair, you can create one using one of the following scripts. Each of them creates a GPG key pair with 3 subkeys:

  • One for Authentication (SSH)
  • One for Signature (Git)
  • One for Encryption (Files, SOPS...)

The created keys are RSA 4096 keys that can be deployed on a yubikey by following the yubikey initialization documentation.

The following New-GPGKeys.ps1 creates the key pair and its subkeys on Windows.

#!/usr/bin/env pwsh
# cSpell: ignore mainkey subkey subkeys

<#
.SYNOPSIS
    Creates GPG keys with main certification key and subkeys for signing, encryption, and authentication.

.DESCRIPTION
    This cmdlet generates a GPG key set consisting of:
    - A main certification key that never expires
    - A signing subkey that expires in 1 year (configurable)
    - An encryption subkey that expires in 1 year (configurable)
    - An authentication subkey that expires in 1 year (configurable)

.PARAMETER Name
    The name to associate with the GPG key.


.PARAMETER Email
    The email address to associate with the GPG key.

.PARAMETER KeyType
    The type of key to generate. Default is "rsa4096".

.PARAMETER ExpireMain
    Expiration time for the main key. Default is "0" (never expires).

.PARAMETER ExpireSub
    Expiration time for subkeys. Default is "1y" (1 year).

.EXAMPLE
    New-GPGKeys -Email "user@example.com" -Name "John Doe"
    Creates GPG keys for John Doe with default settings.

.EXAMPLE
    New-GPGKeys -Email "user@example.com" -Name "John Doe" -KeyType "rsa2048" -ExpireSub "2y"
    Creates GPG keys with RSA 2048-bit keys and subkeys that expire in 2 years.

.NOTES
    This cmdlet requires GPG to be installed and available in the system PATH.
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory = $true, Position = 0)]
    [ValidateNotNullOrEmpty()]
    [string]$Name,

    [Parameter(Mandatory = $true, Position = 1)]
    [ValidateNotNullOrEmpty()]
    [string]$Email,

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$KeyType = "rsa4096",

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$ExpireMain = "0",

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$ExpireSub = "1y"
)

begin {
    Write-Verbose "Starting GPG key generation process"

    # Check if GPG is available
    try {
        $gpgVersion = gpg --version 2>$null
        if (-not $gpgVersion) {
            throw "GPG not found"
        }
        Write-Verbose "GPG is available"
    }
    catch {
        throw "GPG is not installed or not available in PATH. Please install GPG before running this cmdlet."
    }
}

process {
    try {
        $keyIdentity = "`"${Name} <${Email}>`""
        Write-Information "Creating GPG keys for: $keyIdentity" -InformationAction Continue

        if ($PSCmdlet.ShouldProcess($keyIdentity, "Generate GPG main key")) {
            # Generate main key (certification key)
            Write-Verbose "Generating main certification key..."
            $mainKeyArgs = @(
                "--batch"
                "--quick-generate-key"
                $keyIdentity
                $KeyType
                "cert"
                $ExpireMain
            )
            Write-Verbose "Running GPG command: gpg $($mainKeyArgs -join ' ')"

            $result = Start-Process -FilePath "gpg" -ArgumentList $mainKeyArgs -Wait -NoNewWindow -PassThru
            if ($result.ExitCode -ne 0) {
                throw "Failed to generate main key. GPG exited with code $($result.ExitCode)`n$($result)"
            }
            Write-Verbose "Main key generated successfully"
        }

        # Get the fingerprint of the newly created key
        Write-Verbose "Retrieving main key fingerprint..."
        $fpOutput = gpg --list-keys --with-colons $Email 2>$null
        $mainKeyFpr = ($fpOutput | Where-Object { $_ -match "^fpr:" } | Select-Object -First 1) -replace "^fpr:+", "" -replace ":.*$", ""

        if (-not $mainKeyFpr) {
            throw "Could not retrieve fingerprint for the newly created key"
        }
        Write-Verbose "Main key fingerprint: $mainKeyFpr"

        if ($PSCmdlet.ShouldProcess($keyIdentity, "Add signing subkey")) {
            # Add subkey: Signing
            Write-Verbose "Adding signing subkey..."
            $signingKeyArgs = @(
                "--batch"
                "--quick-add-key"
                $mainKeyFpr
                $KeyType
                "sign"
                $ExpireSub
            )

            $result = Start-Process -FilePath "gpg" -ArgumentList $signingKeyArgs -Wait -NoNewWindow -PassThru
            if ($result.ExitCode -ne 0) {
                throw "Failed to add signing subkey. GPG exited with code $($result.ExitCode)"
            }
            Write-Verbose "Signing subkey added successfully"
        }

        if ($PSCmdlet.ShouldProcess($keyIdentity, "Add encryption subkey")) {
            # Add subkey: Encryption
            Write-Verbose "Adding encryption subkey..."
            $encryptionKeyArgs = @(
                "--batch"
                "--quick-add-key"
                $mainKeyFpr
                $KeyType
                "encrypt"
                $ExpireSub
            )

            $result = Start-Process -FilePath "gpg" -ArgumentList $encryptionKeyArgs -Wait -NoNewWindow -PassThru
            if ($result.ExitCode -ne 0) {
                throw "Failed to add encryption subkey. GPG exited with code $($result.ExitCode)"
            }
            Write-Verbose "Encryption subkey added successfully"
        }

        if ($PSCmdlet.ShouldProcess($keyIdentity, "Add authentication subkey")) {
            # Add subkey: Authentication
            Write-Verbose "Adding authentication subkey..."
            $authKeyArgs = @(
                "--batch"
                "--quick-add-key"
                $mainKeyFpr
                $KeyType
                "auth"
                $ExpireSub
            )

            $result = Start-Process -FilePath "gpg" -ArgumentList $authKeyArgs -Wait -NoNewWindow -PassThru
            if ($result.ExitCode -ne 0) {
                throw "Failed to add authentication subkey. GPG exited with code $($result.ExitCode)"
            }
            Write-Verbose "Authentication subkey added successfully"
        }

        Write-Information "GPG key generation completed successfully!" -InformationAction Continue
        Write-Information "Main key expires: $(if ($ExpireMain -eq '0') { 'Never' } else { $ExpireMain })" -InformationAction Continue
        Write-Information "Subkeys expire: $ExpireSub" -InformationAction Continue

        # Return key information
        [PSCustomObject]@{
            Email = $Email
            Name = $Name
            KeyType = $KeyType
            MainKeyFingerprint = $mainKeyFpr
            MainKeyExpiration = $ExpireMain
            SubKeyExpiration = $ExpireSub
            Created = Get-Date
        }
    }
    catch {
        Write-Error "Failed to create GPG keys: $($_.Exception.Message)"
        throw
    }
}

end {
    Write-Verbose "GPG key generation process completed"
}

You invoke it with:

.\New-GPGKeys.ps1 -Name "Antoine Martin" -Email "antoine@mrtn.fr"

The following create_keys.sh creates the key pair and its subkeys on Linux. You invoke it with:

#!/bin/zsh
# cSpell: ignore mainkey subkey subkeys

# Default values
KEY_TYPE="rsa4096"
EXPIRE_MAIN="0"
EXPIRE_SUB="1y"

# Usage function
usage() {
    echo "Usage: $0 [-t key_type] <email> <name>"
    echo "  -t key_type  Key type (default: rsa4096)"
    echo "  email        Email address for the key"
    echo "  name         Name for the key"
    exit 1
}

# Parse options
while getopts "t:h" opt; do
    case $opt in
        t)
            KEY_TYPE="$OPTARG"
            ;;
        h)
            usage
            ;;
        \?)
            echo "Invalid option: -$OPTARG" >&2
            usage
            ;;
    esac
done

# Shift past the options
shift $((OPTIND-1))

# Check if we have the required parameters
if [ $# -ne 2 ]; then
    echo "Error: Email and name are required parameters"
    usage
fi

KEY_EMAIL="$1"
KEY_NAME="$2"

# Generate main key (never expires)
gpg --batch --quick-generate-key \
    "${KEY_NAME} <${KEY_EMAIL}>" \
    "${KEY_TYPE}" \
    cert \
    "${EXPIRE_MAIN}"

# Get the fingerprint of the newly created key
MAINKEY_FPR=$(gpg --list-keys --with-colons "${KEY_EMAIL}" | awk -F: '/^fpr:/ {print $10; exit}')

# Add subkey: RSA 4096 Signing, expires in one year
gpg --batch --quick-add-key "${MAINKEY_FPR}" "${KEY_TYPE}" sign "${EXPIRE_SUB}"

# Add subkey: RSA 4096 Encryption, expires in one year
gpg --batch --quick-add-key "${MAINKEY_FPR}" "${KEY_TYPE}" encrypt "${EXPIRE_SUB}"

# Add subkey: RSA 4096 Authentication, expires in one year
gpg --batch --quick-add-key "${MAINKEY_FPR}" "${KEY_TYPE}" auth "${EXPIRE_SUB}"

echo "Done. Main key never expires. Subkeys expire in 1 year."
./create_keys.sh -n "Antoine Martin" -e "antoine@mrtn.fr"

Multiple password queries

You will be asked four (4) times for a password. The first one, it's for creating the main key pair. The 3 following requests are for signing the subkeys with the main key. In consequence you need to always enter the same password.

Add keys to SSH agent

There are two ways for the keys to be recognized by the SSH agent:

  • Their key grip is added to the SSH control file, $Env:APPDATA\gnupg\sshcontrol. This method is now deprecated.
  • They have the attribute Use-for-ssh set to true.

Adding a GPG key subkey

To add the authentication subkey of an existing key, you can use the following command:

PS> gpg -K --with-keygrip --with-colon <key id> |`
  % -Begin { $s=$false } {$a = ($_ -split ':'); if ($a[11] -eq 'a') { $s=$true } else { if ($s -and $a[0] -eq 'grp') { $s=$false; $a[9] }}} |`
  % { gpg-connect-agent "keyattr $_ Use-for-ssh: true" /bye }
OK
PS>

Once this is done, the public part of the key should appear in the output of ssh-add -L both on Windows and inside the Wsl instances:

PS> ssh-add -L
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDeiN0u+kazFEkAYr2BTPT2I2q9dPyioPpaYVLfO+W5gzjX1W+Ad856w0YYNunAfDCc7cIJE553rC72xX/7W136Bz7bZPqE5f3YQnYqAFkuO2jR3buLxzHaBctE8pAGxkCkO9nyT9w3CRZ3KC2rSoc5i+RxftCraAyJ2HKHlnKoLkJN9pW3yzZv0suOniTGEZWlqZU/B9Z6DS0z1Ym+HNUr4L5p7t7uAp1HDgz5KK/aD+yJ7S1epitICZWg2+exOp1XrtM6+eabQFatkpwVJO7GtsJZYTjX71txx0jc6ysNdptj4ifHhenISdD/8vPQhkVoMeBlj3UgakBKAjBHZcrDmnhpjDIW/rAd/FdQOKIpvaJo92cPM1kP5WAuwFgoKW9aU/7Wy4xCooPE24/ADg7fxpyYHpa+UBjgfpROPBOVmkl6RnUqUOa5NzfdQz2RSapPRdEWvl4fMlP1U8CxuAPEFcqUkq727TGiXsFtT1lJ42gvl2Gr4OmNzUI9JOhg7MEs1kAhOZkcK0MGGnRO6qCfwhPm7qlq7pvHndWJb8m0fTN9hqeLpeU7jXeptURyI/Xhslg/jwlMUlmn5TIoyDS4uRDDFJuzlyJQFMcaVuSutaXfRC8W6cygumtmZHEs5nWSJYqyhaDcw79/SVb61Pr95lFq7i+yRS1YsI9UfYMNAQ== cardno:15_791_607
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDmcrHEQSPYbsdv2BmG2+B9we9F1TS7d+m0I+rR/ZR9nVcmE9GhEl5Fq67nb+LxTXhZPzm28BwOBGcKOi44UFfgiivuOLcPyRrXF7riuQjFY+uTi1URspE13/sjGLRpcjAm5YzIDnGc60bMjUSh4SlvWFwWSSLO3fTIg++RHwH1H+knDQ7RZl546nKwAiO36cs2f7OWpcIKy5hD8oOYKZAOZxUFz00ZcxmTlJNW8IDSpWLuWCGEEFRXoV35CYq/d6DRMzd36r1Az/9J7q4JaUnZCWo89BuKZagd3dG5/0idatpu8HH6pgl6XCOS2Qvbz7SLOoYNjcevKKbsotdS1N4haLFiAu/OAL2+3q7z9IymfAlgIY6SxoJaWHNbvNUbeKdbKxfJwkc2QtM6gkARlO+1xHO7JORr9bvtGUh1lr0dOsbzBlkoXUfQZUxepeY5jML5am9W1NffKaFCbIEzigge6xvExWrZOvjQ2Vf3LRpGGQY9q28nB8dsNXNPPM0h4P99SC6tOwiiGjJXKL4io6OnJUcxcVpYkfGr7LTymyCHutw1X9YNbghjmNnjgF+mIDML4xWN///N0hL8rZwA7W7h1OHipW1A0QqhnEfkSVfREG5bFyN+P7LBo7+LVieA5LaZ8Cu+jbdHDt2s061z14ase6itl+evvc7NsucICmcknw== (none)
PS>

It can be removed with:

PS> gpg -K --with-keygrip --with-colon <key id> |`
  % -Begin { $s=$false } {$a = ($_ -split ':'); if ($a[11] -eq 'a') { $s=$true } else { if ($s -and $a[0] -eq 'grp') { $s=$false; $a[9] }}} |`
  % { gpg-connect-agent "keyattr $_ Use-for-ssh: false" /bye }
OK
PS>

Import existing ssh keys

Existing SSH keys can be imported with the ssh-add command:

PS> ssh-add <path to private key>

This will add the private key to the PGP ring and add it to the sshcontrol file. The ssh-agent will then be able to use the key for authentication. The public key can be listed with:

PS> ssh-add -L

There is however some limitations with this approach:

  • the key does not appear in the normal output of gpg because it is not tied to a GPG key.
  • The key is in the sshcontrol file, which is not managed by GPG and is deprecated. It's better to use the Use-for-ssh attribute to manage SSH keys.

The following PowerShell Cmdlet (Add-SSHKeyToGPG.ps1 ) allows adding a ssh key and attach it to an existing GPG key:

<#
.SYNOPSIS
    Adds an SSH key to a GPG key for SSH authentication using GPG agent.

.DESCRIPTION
    The Add-SSHKeyToGPG cmdlet integrates an SSH private key with a GPG key to enable SSH authentication
    through the GPG agent. This allows you to use your GPG key for SSH operations while maintaining
    centralized key management through GPG.

    The cmdlet performs the following operations:
    1. Retrieves the SSH key fingerprint from the specified key file
    2. Adds the SSH key to the SSH agent if not already present
    3. Obtains the key grip from GPG agent
    4. Sets the Use-for-ssh attribute for the key grip
    5. Cleans up duplicate entries in the sshcontrol file
    6. Associates the SSH key with the specified GPG key

    This is particularly useful for developers who want to use hardware security keys or centralized
    GPG key management for SSH authentication.

.PARAMETER KeyPath
    Specifies the path to the SSH private key file that should be added to the GPG key.
    This parameter is mandatory and must point to a valid SSH private key file.

.PARAMETER GPGKeyID
    Specifies the GPG key ID (short ID, long ID, or fingerprint) to which the SSH key should be added.
    This parameter is mandatory and must reference an existing GPG key in your keyring.

.PARAMETER GPGKeySecret
    Specifies the passphrase for the GPG key as a SecureString object.
    This parameter is mandatory and is used to unlock the GPG key during the operation.

    To create a SecureString from user input:
    Read-Host -AsSecureString -Prompt "Enter GPG key passphrase"

    To create a SecureString from plain text:
    ConvertTo-SecureString "your-passphrase" -AsPlainText -Force

.EXAMPLE
    $passphrase = Read-Host -AsSecureString -Prompt "Enter GPG key passphrase"
    Add-SSHKeyToGPG -KeyPath "~/.ssh/id_rsa" -GPGKeyID "1234567890ABCDEF" -GPGKeySecret $passphrase

    This example adds the SSH key located at ~/.ssh/id_rsa to the GPG key with ID 1234567890ABCDEF,
    prompting the user for the GPG key passphrase securely.

.EXAMPLE
    $securePass = ConvertTo-SecureString "MyGPGPassphrase" -AsPlainText -Force
    Add-SSHKeyToGPG -KeyPath "C:\Users\User\.ssh\id_ed25519" -GPGKeyID "user@example.com" -GPGKeySecret $securePass

    This example adds an Ed25519 SSH key to a GPG key identified by email address, using a passphrase
    converted from plain text (not recommended for production use).

.EXAMPLE
    Add-SSHKeyToGPG -KeyPath ".\mykey" -GPGKeyID "ABCD1234" -GPGKeySecret $pass -WhatIf

    This example shows what would happen when adding the SSH key without actually performing the operation,
    using the -WhatIf parameter for testing.

.INPUTS
    None. This cmdlet does not accept pipeline input.

.OUTPUTS
    None. This cmdlet does not generate output objects. It provides informational messages about the operations performed.

.NOTES
    Prerequisites:
    - GPG (GNU Privacy Guard) must be installed and configured (gpg4win on Windows, gpg on Linux/Mac)
    - SSH client tools must be available (ssh-keygen, ssh-add)
    - GPG agent must be running and configured for SSH support (enable-ssh-support, enable-putty-support
      enable-win32-openssh-support in gpg-agent.conf)
    - The specified SSH key file must exist and be accessible
    - The specified GPG key must exist in your GPG keyring with a secret key available

    Security Considerations:
    - Always use SecureString for the GPG passphrase parameter
    - Ensure your GPG agent is properly configured with appropriate cache timeouts
    - The SSH key will be loaded into both SSH agent and GPG agent memory

    File Modifications:
    - This cmdlet may modify the sshcontrol file in your GPG configuration directory
    - Backup your GPG configuration before running if you have custom sshcontrol settings

    Troubleshooting:
    - If the key grip cannot be found, ensure the SSH key is properly formatted and accessible
    - If GPG operations fail, verify that gpg-agent is running and properly configured
    - Use -Verbose parameter to see detailed operation information

.LINK
    https://gnupg.org/documentation/manuals/gnupg/

.LINK
    https://wiki.gnupg.org/AgentForwarding

.COMPONENT
    GPG, SSH, Security

.ROLE
    Security, KeyManagement

.FUNCTIONALITY
    SSH key management, GPG integration, Authentication
#>

# cSpell: ignore keygen keyinfo keyattr pinentry sshcontrol sshcontrols keygrip
using namespace System.Diagnostics.Process

[CmdletBinding(SupportsShouldProcess=$true)]
param(
    [Parameter(Mandatory=$true, Position=0)]
    [string]$KeyPath,
    [Parameter(Mandatory=$true, Position=1)]
    [string]$GPGKeyID,
    [Parameter(Mandatory=$true, Position=2)]
    # To obtain the secret string, use: Read-Host -AsSecureString -Prompt "Enter GPG key passphrase"
    # Or convert from plain text: ConvertTo-SecureString "your-passphrase" -AsPlainText -Force
    [SecureString]$GPGKeySecret
)

# Get the Fingerprint of the SSH key
$SSHKeyFingerprint = (ssh-keygen -l -f $KeyPath) -split ' ' | Select-Object -Index 1
Write-Verbose "SSH Key Fingerprint: $SSHKeyFingerprint"

if ((ssh-add -l) -match $SSHKeyFingerprint) {
    Write-Information "SSH key with fingerprint $SSHKeyFingerprint is already added to SSH Agent. Skipping addition." -InformationAction Continue
} else {
    # Add the SSH key to the SSH Agent
    if ($PSCmdlet.ShouldProcess($KeyPath, "Add SSH key with fingerprint $SSHKeyFingerprint to SSH Agent")) {
        Write-Information "Adding SSH key $KeyPath to SSH Agent. KEEP PASSWORD BLANK!..." -InformationAction Continue
        ssh-add $KeyPath | Out-Null
    }
}

# Now retrieve the key grip of the key
$KeyGrip = gpg-connect-agent "KEYINFO --list --ssh-fpr" /bye | ForEach-Object { ,$_.Split(' ') } | Where-Object { $_[8] -eq $SSHKeyFingerprint } | ForEach-Object { $_[2] }
if (-not $KeyGrip) {
    throw "Key grip not found for SSH key fingerprint $SSHKeyFingerprint"
}
Write-Verbose "Key Grip: $KeyGrip"

# Set the key Use-for-ssh attribute
if ($PSCmdlet.ShouldProcess($KeyGrip, "Set Use-for-ssh attribute for key grip $KeyGrip")) {
    Write-Information "Setting Use-for-ssh attribute for key grip $KeyGrip..." -InformationAction Continue
    gpg-connect-agent "KEYATTR $KeyGrip Use-for-ssh: true" /bye | Out-Null
}

# Now clean sshcontrols
$SshControlFile = "$Env:APPDATA\gnupg\sshcontrol"
if (Test-Path $SshControlFile) {
    if ($PSCmdlet.ShouldProcess($SshControlFile, "Clean sshcontrol file to remove duplicates")) {
        $SshControlContent = Get-Content $SshControlFile -Raw
        if ($SshControlContent -notmatch $KeyGrip) {
            Write-Information "KeyGrip $KeyGrip not found in sshcontrol file. No cleaning needed." -InformationAction Continue
        } else {
            Write-Information "Cleaning sshcontrol file $SshControlFile of $SSHKeyFingerprint and $KeyGrip..." -InformationAction Continue
            $ReplaceRegex = "(?s)# RSA key added on: .*?`n# Fingerprints:.*?`n#\s+$($SSHKeyFingerprint).*?`n$KeyGrip\s+\d+\s*`n"
            Write-Verbose "Regex to remove:`n$ReplaceRegex"
            $SshControlContent = $SshControlContent -replace $ReplaceRegex, ''
            Write-Verbose "Updated sshcontrol content:`n$SshControlContent"
            $SshControlContent | Set-Content -NoNewline $SshControlFile
        }
    }
}

if ((gpg --list-keys --with-keygrip $GPGKeyID) -match $KeyGrip) {
    Write-Information "SSH key with key grip $KeyGrip is already associated with GPG key $GPGKeyID. Skipping addition." -InformationAction Continue
    return
} else {
    # Now add the SSH key to the GPG key
    if ($PSCmdlet.ShouldProcess($GPGKeyID, "Add SSH key with key grip $KeyGrip to GPG key $GPGKeyID")) {
        Write-Verbose "Adding SSH key with key grip $KeyGrip to GPG key $GPGKeyID..."

        $process = New-Object System.Diagnostics.Process
        $process.StartInfo.FileName = "gpg.exe"
        $process.StartInfo.Arguments =  '--status-fd 2', '--verbose', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '--command-fd', '0', '--expert', '--edit-key', $GPGKeyID, 'addkey'

        $process.StartInfo.RedirectStandardOutput = $true
        $process.StartInfo.RedirectStandardError = $true
        $process.StartInfo.RedirectStandardInput = $true
        $process.StartInfo.UseShellExecute = $false
        $process.StartInfo.CreateNoWindow = $true
        $process.StartInfo.ErrorDialog = $false
        $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8
        $process.StartInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8
        $process.StartInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
        $process.EnableRaisingEvents = $true
        $process.StartInfo.EnvironmentVariables["LANG"] = "C" # Ensure consistent language for parsing

        # Use asynchronous reading to avoid hanging
        $outputBuilder = New-Object System.Text.StringBuilder
        $errorBuilder = New-Object System.Text.StringBuilder

        $outputEvent = Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -Action {
            param
            (
                [System.Object] $sender,
                [System.Diagnostics.DataReceivedEventArgs] $e
            )
            Write-Verbose "O[$($e.Data )]"
        } -MessageData $outputBuilder

        $errorEvent = Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -Action {        param
            (
                [System.Object] $sender,
                [System.Diagnostics.DataReceivedEventArgs] $e
            )
            Write-Verbose "E[$($e.Data )]"
        } -MessageData $errorBuilder

        $process.Start() | Out-Null
        $process.StandardInput.AutoFlush = $true

        Write-Verbose "Process started. PID: $($process.Id)"

        $process.BeginOutputReadLine()
        $process.BeginErrorReadLine()

        # Wait a moment for initial output
        Start-Sleep -Milliseconds 500

        Write-Verbose "Sending commands to GPG..."
        # cSpell: ignore addkey
        $process.StandardInput.WriteLine([System.Net.NetworkCredential]::new("", $GPGKeySecret).Password)
        $process.StandardInput.WriteLine("13")
        $process.StandardInput.WriteLine($KeyGrip)
        $process.StandardInput.WriteLine("S")
        $process.StandardInput.WriteLine("E")
        $process.StandardInput.WriteLine("A")
        $process.StandardInput.WriteLine("Q")
        $process.StandardInput.WriteLine("1y")
        $process.StandardInput.WriteLine("save")
        $process.StandardInput.Close()

        $process.WaitForExit()

        # Clean up event handlers
        Unregister-Event -SourceIdentifier $outputEvent.Name
        Unregister-Event -SourceIdentifier $errorEvent.Name
    }
    Write-Information "SSH key with fingerprint $SSHKeyFingerprint added to GPG key $GPGKeyID." -InformationAction Continue
}

You run the following commands to add the SSH key to the GPG key:

PS> $passphrase = Read-Host -AsSecureString -Prompt "Enter GPG key passphrase"
Enter GPG key passphrase: *******
PS> .\Add-SSHKeyToGPG.ps1 $HOME\.ssh\id_rsa antoine@mrtn.fr $passphrase
Adding SSH key C:\Users\AntoineMartin\.ssh\id_rsa to SSH Agent...
Identity added: C:\Users\AntoineMartin\.ssh\id_rsa (antoinemartin@AMG16)
Setting Use-for-ssh attribute for key grip 2574132216E62017A12AF70DC0C21BCB3344F582...
Cleaning sshcontrol file C:\Users\AntoineMartin\AppData\Roaming\gnupg\sshcontrol of SHA256:WD68qGMoS16T/Re8hHRP3JAzZGTN5Q890jbr1Od0RGo and 2574132216E62017A12AF70DC0C21BCB3344F582...
SSH key with fingerprint SHA256:WD68qGMoS16T/Re8hHRP3JAzZGTN5Q890jbr1Od0RGo added to GPG key antoine@mrtn.fr.
PS>

Import public GPG keys in WSL

To be able to use one of your GPG key in WSL for git signatures, you need to import the public key into the local keyring inside the WSL instance.

To export a public key from Windows and import it on WSL, you can use the following commands from PowerShell on Windows:

PS> gpg --armor --export antoine@mrtn.fr | wsl -d alpine322 gpg --import
gpg: key D0F36C61: 2 signatures not checked due to missing keys
gpg: key D0F36C61: public key "Antoine Martin <antoine@mrtn.fr>" imported
gpg: Total number processed: 1
gpg:               imported: 1
gpg: marginals needed: 3  completes needed: 1  trust model: pgp
gpg: depth: 0  valid:   1  signed:   0  trust: 0-, 0q, 0n, 0m, 0f, 1u
PS>

Tips

Change the location of the GPG agent socket

Sometimes you want to create a separate temporary GPG Home.

> export GNUPGHOME=$(mktemp -d)
> gpg -k
gpg: keybox '/tmp/tmp.dlkMBb/pubring.kbx' created
gpg: /tmp/tmp.dlkMBb/trustdb.gpg: trustdb created

But when you do that, GPG assumes that the GPG agent socket is located in the same directory. I you sill want to communicate with the Windows based agent, you need to create a special file named S.gpg-agent with the following content:

%Assuan%
socket=/home/<username>/S.gpg-agent

You can do that with the following command:

rm -f $GNUPGHOME/S.gpg-agent
cat - > $GNUPGHOME/S.gpg-agent <<EOF
%Assuan%
socket=$HOME/.gnupg/S.gpg-agent
EOF

Source: this stackoverflow entry.

Files location

  • SSH Agent socket: ~/.ssh/agent.sock
  • GPG Agent socket: ~/.gnupg/S.gpg-agent
  • zsh plugin: /usr/share/oh-my-zsh/custom/plugins/wsl2-ssh-pageant

The location of the Windows named Pipes is given by the following command:

PS> gpgconf --list-dir socketdir
C:\Users\AntoineMartin\AppData\Local\gnupg
  • SSH Agent socket: $Env:LOCALAPPDATA\gnupg\S.gpg-agent
  • GPG Agent socket: $Env:LOCALAPPDATA\gnupg\S.gpg-agent.ssh

The location of the configuration files is given by the following command:

PS>  gpgconf --list-dir homedir
C:\Users\AntoineMartin\AppData\Roaming\gnupg

The relevant files are:

  • GnuPG configuration: $Env:APPDATA\gnupg\gpg.conf
  • GnuPG agent configuration: $Env:APPDATA\gnupg\gpg-agent.conf
  • GnuPG ssh configuration: $Env:APPDATA\gnupg\sshcontrol