Sign Git commits from WSL using a certificate stored on a YubiKey (PIV), by delegating to gpgsm.exe running on the Windows host via Gpg4win.
WSL does not have native access to smart-card readers, so gpgsm inside WSL cannot talk to a YubiKey directly. This setup works around that by:
- Configuring Gpg4win on Windows to talk to the YubiKey over PCSC (SCardSvr).
- Providing a thin wrapper script (
gpgsm-wsl) inside WSL that translates Linux paths to Windows paths and forwards all calls togpgsm.exeon the host. - Pointing Git's X.509 signing pipeline at that wrapper.
The following sequence diagram shows how a signed Git operation flows from WSL through the wrapper to Windows gpgsm.exe, the GnuPG agent stack, PCSC, and the YubiKey.
sequenceDiagram
autonumber
participant U as User WSL shell
participant G as Git WSL
participant W as gpgsm-wsl wrapper
participant T as wslpath and temp file
participant X as Windows gpgsm from Gpg4win
participant A as Windows gpg agent
participant S as scdaemon Windows
participant P as PCSC SCardSvr
participant Y as YubiKey PIV 9a
U->>G: git commit signing or verify
G->>W: run gpg.x509.program
Note over W: parse argv translate paths buffer stdin if needed
alt path is Linux absolute
W->>T: run wslpath for path
T-->>W: UNC to WSL filesystem
else stdin sentinel for gpgsm
G->>W: payload on stdin
W->>T: write temp file run wslpath
T-->>W: UNC to stdin buffer
end
Note over W: Flags and fingerprints pass through without path translation
W->>X: exec with translated argv
X->>A: sign or verify
A->>S: smartcard op
S->>P: PCSC
P->>Y: APDU PIV 9a
Y-->>P: signature or card result
P-->>S: response
S-->>A: result
A-->>X: CMS output
X-->>G: exit code stdout stderr
G-->>U: signed commit or verify outcome
Note over W,T: EXIT trap removes temp dir and stdin file
Note over P,Y: Key stays on Windows USB no passthrough to WSL
- Windows with a YubiKey (PIV applet) plugged in
- Gpg4win installed (GnuPG is required; Kleopatra is optional)
- WSL 2 (any distro)
This section covers creating a self-signed CA, generating a CSR from the YubiKey, signing it, and importing the resulting certificate back onto the key.
Use YubiKey Manager to generate a CSR directly on the key so that the private key never leaves the hardware.
In the YubiKey Manager GUI:
- Go to Applications → PIV → Certificates → Authentication (9a)
- Click Generate → choose Certificate Signing Request (CSR)
- Fill in the subject (e.g.
CN=Your Name,E=you@example.com) - Save the CSR as
csr-9a.csr
Slot 9a (Authentication) is the one confirmed to work with this setup. I was not able to make 9c/9d work.
# Generate the CA private key
openssl genrsa -out ca.key 4096
# Self-sign the CA certificate (10-year validity)
openssl req -x509 -new -nodes -key ca.key -days 3650 -out ca.crt -subj "/CN=Cat Factory CA/"Keep ca.key and ca.crt somewhere safe — you will need ca.crt later when importing into Gpg4win so it trusts signatures made by this CA.
Edit openssl.config and update the email address in the [ san ] section to match your Git identity.
The otherName OID (1.3.6.1.4.1.311.20.2.3) is the Microsoft UPN SAN, which Gpg4win/gpgsm uses to match a certificate to a Git user.signingkey value.
openssl x509 -req \
-days 3650 \
-in csr-9a.csr \
-CA ca.crt \
-CAkey ca.key \
-CAcreateserial \
-out cert.pem \
-extfile openssl.config \
-extensions v3_reqVerify the extensions were applied correctly:
openssl x509 -in cert.pem -noout -text"In YubiKey Manager:
- Go to Applications → PIV → Certificates → Authentication (9a)
- Click Import and select
cert.pem
Download and install from https://www.gpg4win.org/. Only GnuPG is required, Kleopatra is not needed but nice to have.
Edit %APPDATA%\gnupg\gpg-agent.conf (create it if it does not exist):
pinentry-program "C:\Program Files\Gpg4win\bin\pinentry.exe"
# log-file C:\temp\gpg-agent.log
# debug-level basic
Edit %APPDATA%\gnupg\scdaemon.conf (create it if it does not exist):
disable-ccid
pcsc-shared
application-priority piv
# log-file C:\temp\scd.log
# debug-level guru
disable-ccid tells scdaemon to use the system PCSC stack instead of its own CCID driver, which is required for YubiKey PIV to work reliably on Windows.
Open PowerShell or Command Prompt and run:
& 'C:\Program Files\GnuPG\bin\gpgconf.exe' --kill allcertutil.exe -scinfoYour certificate should appear in the output. If it does not, check that the YubiKey PIV applet is initialised and that the smartcard service is running.
Also verify that GnuPG itself can see the card:
& 'C:\Program Files\GnuPG\bin\gpg.exe' --card-statusThis should print the card serial number, reader name, and key information. If it fails, scdaemon is not communicating with the card.
gpgsm --learn-card fails to import the certificate if the CA is not trusted yet. It can reach the card and create key stubs, but then bails out before completing the certificate import. The fix is to import and trust the CA first, then run --learn-card.
& 'C:\Program Files\GnuPG\bin\gpgsm.exe' --import ca.crtGet the CA fingerprint:
& 'C:\Program Files\GnuPG\bin\gpgsm.exe' --list-keysThe fingerprint is shown with colons (e.g. AB:CD:EF:...). Strip the colons and add a line to %APPDATA%\gnupg\trustlist.txt (create the file if it does not exist):
ABCDEF... S
The S flag marks it as a trusted S/MIME root CA, which is what gpgsm uses for X.509 signing.
& 'C:\Program Files\GnuPG\bin\gpgconf.exe' --kill all& 'C:\Program Files\GnuPG\bin\gpgsm.exe' --learn-card# Should show the certificate
& 'C:\Program Files\GnuPG\bin\gpgsm.exe' --list-keys
# Should show the card-backed secret key stub
& 'C:\Program Files\GnuPG\bin\gpgsm.exe' --list-secret-keysBoth should return the same certificate. Copy the fingerprint from --list-keys — you will need it for the Git configuration in the next section.
Copy gpgsm-wsl from this repository to /usr/bin/ and make it executable:
sudo cp ./gpgsm-wsl /usr/bin/gpgsm-wsl
sudo chmod +x /usr/bin/gpgsm-wslThe script does the following:
- Path translation — converts absolute Linux paths (e.g.
/home/user/file) to their Windows equivalents usingwslpath. - Stdin forwarding — when Git passes
-to signal that signed data should be read from stdin, the script buffers it to a temporary file and passes the Windows path togpgsm.exeinstead, since cross-OS stdin piping is unreliable. - Card wake-up — runs
gpg.exe --card-statuswith stdin from/dev/nulland output discarded beforegpgsm.exeso scdaemon/PCSC is ready. Without redirecting stdin,gpgcould consume the commit data Git pipes in for signing and the signature would be wrong. This also avoids a first-use "insert card" prompt right after another stack touched the YubiKey (for example SSH via PKCS#11).
Find your certificate fingerprint from the previous step (gpgsm --list-keys), then configure Git globally:
git config --global gpg.format x509
git config --global gpg.x509.program gpgsm-wsl
git config --global commit.gpgsign true
git config --global user.signingkey "<fingerprint>"Replace <fingerprint> with the fingerprint of your signing certificate as shown by --list-keys.
Make a test commit and confirm the signature is present:
git commit --allow-empty -m "test: verify gpgsm signing"
git verify-commit HEADYou should see a line like Good signature from [...] in the output.
| Symptom | Likely cause | Fix |
|---|---|---|
gpgsm.exe cannot find the key |
gpg-agent not running | Run gpgconf.exe --kill all and retry |
| PIN prompt never appears | Wrong pinentry-program path |
Verify the path in gpg-agent.conf matches your Gpg4win install |
certutil -scinfo shows no card |
PCSC service stopped or YubiKey not inserted | Restart the Smart Card service (SCardSvr) via services.msc or run Restart-Service SCardSvr in PowerShell, then replug the YubiKey |
wslpath: invalid argument |
Path contains characters special to WSL | Ensure filenames do not contain backslashes or colons |
| First sign says insert card, second works (e.g. after SSH with PKCS#11) | PCSC reader handoff between clients | The wrapper runs gpg.exe --card-status before each gpgsm call; update the script if yours is older. Or run gpg.exe --card-status once in PowerShell before signing |
To enable verbose logging, uncomment the log-file and debug-level lines in the config files and restart the agent/daemon.