Skip to content

GPG Signing with Ledger Hardware Wallet

This document describes how to use your Ledger hardware wallet for GPG signing, including git commit signatures, message signing, and file encryption.



The Ledger hardware wallet can be used for GPG operations:

  • GPG key generation and storage - Keys never leave the device
  • Git commit signing - Cryptographic proof of authorship
  • Message signing - Sign any text or file
  • Encryption/decryption - Secure message handling
  • SSH authentication - See SSH Authentication

All operations require physical confirmation on your Ledger device, providing hardware-level security.


┌─────────────────────────────────────────────────────────────┐
│ Commit Signing Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ git commit │
│ │ │
│ ▼ │
│ gpg-ledger wrapper script │
│ │ │
│ ├─> Check if ledger-gpg-agent running │
│ │ If not: start agent │
│ │ │
│ ▼ │
│ gpg --homedir ~/.gnupg-ledger │
│ │ │
│ ▼ │
│ ledger-gpg-agent │
│ │ │
│ ▼ │
│ Ledger device (sign with hardware key) │
│ │ │
│ ▼ │
│ User confirms on device │
│ │ │
│ ▼ │
│ Signed commit │
│ │
└─────────────────────────────────────────────────────────────┘
  1. Ledger device - Stores private key, performs signing operations
  2. ledger-gpg-agent - Bridges GPG and Ledger device
  3. gpg-ledger wrapper - Auto-manages agent lifecycle
  4. Git configuration - Uses wrapper for all GPG operations

  • Ledger Nano S device
  • SSH/GPG Agent app installed on Ledger (via Ledger Live)
  • ledger-agent package (included in this config)
  • GPG key initialized on Ledger

If you haven’t completed setup, see Ledger Setup Guide.


This configuration uses a GPG key stored on your Ledger:

  • Key ID: D2A7EC63E350CC488197CB2ED369B07E00FB233E
  • Identity: Luc Chartier luc@distorted.media
  • Algorithm: ECDSA (NIST P-256 curve)
  • Key Location: Ledger hardware wallet
  • Keyring: ~/.gnupg-ledger/

If you need to initialize your GPG key on the Ledger:

Terminal window
ledger-gpg init "Your Name <your.email@example.com>" --homedir ~/.gnupg-ledger

Important:

  • Use --time=0 flag to regenerate the same key deterministically
  • Keys are stored on Ledger and never exposed to computer
  • Recoverable using your Ledger’s 24-word recovery seed
Terminal window
# To file
gpg --homedir ~/.gnupg-ledger --armor --export luc@distorted.media > public-key.asc
# To clipboard (macOS)
gpg --homedir ~/.gnupg-ledger --armor --export luc@distorted.media | pbcopy

Your configuration automatically signs all commits:

✅ GPG key configured: D2A7EC63E350CC488197CB2ED369B07E00FB233E ✅ Sign by default: true ✅ Wrapper script: Auto-manages ledger-gpg-agent ✅ Both gpg.program and gpg.openpgp.program configured

Configuration in nix/profiles/hardware-security.nix:

programs.git = {
signing = {
key = "D2A7EC63E350CC488197CB2ED369B07E00FB233E";
signByDefault = true;
};
extraConfig = {
gpg = {
program = "...gpg-ledger wrapper...";
openpgp.program = "...gpg-ledger wrapper...";
};
};
};

Commits are automatically signed:

Terminal window
git add .
git commit -m "your commit message"

Your Ledger will:

  1. Display “Sign message” on the screen
  2. Wait for you to press the button to confirm
  3. Complete the signature and return to git

Check the signature on the last commit:

Terminal window
git log --show-signature -1

Expected output:

gpg: Signature made Fri Oct 3 06:24:44 2025 PDT
gpg: using ECDSA key D2A7EC63E350CC488197CB2ED369B07E00FB233E
gpg: Good signature from "Luc Chartier <luc@distorted.media>"

Show commits with signature info:

Terminal window
git log --show-signature

Show compact signature status:

Terminal window
git log --pretty=format:"%h %G? %s"

Legend:

  • G = Good signature
  • B = Bad signature
  • U = Good signature, unknown validity
  • N = No signature

To sign a specific commit without auto-signing:

Terminal window
# Disable auto-signing for this repo
git config commit.gpgsign false
# Sign a specific commit manually
git commit -S -m "manually signed commit"
# Or sign the last commit
git commit --amend -S --no-edit

Terminal window
echo "test message" | gpg --homedir ~/.gnupg-ledger --clearsign

Output:

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
test message
-----BEGIN PGP SIGNATURE-----
...signature...
-----END PGP SIGNATURE-----
Terminal window
gpg --homedir ~/.gnupg-ledger --sign file.txt

Creates file.txt.gpg (binary signature).

Terminal window
gpg --homedir ~/.gnupg-ledger --detach-sign file.txt

Creates file.txt.sig (separate signature file).

Terminal window
gpg --homedir ~/.gnupg-ledger --verify file.txt.sig file.txt
Terminal window
echo "secret message" | gpg --homedir ~/.gnupg-ledger --encrypt --recipient luc@distorted.media
Terminal window
gpg --homedir ~/.gnupg-ledger --decrypt encrypted.gpg

  1. Export your public key:

    Terminal window
    gpg --homedir ~/.gnupg-ledger --armor --export luc@distorted.media | pbcopy
  2. Add to GitHub:

    • Go to Settings → SSH and GPG keys
    • Click “New GPG key”
    • Paste your public key
    • Click “Add GPG key”
  3. Verify on GitHub:

    • Your commits will show a “Verified” badge
    • Click the badge to see signature details
  1. Export public key (same as above)
  2. Go to Preferences → GPG Keys
  3. Click “Add new key”
  4. Paste and save
  • Verified: Signature valid, key trusted by platform
  • Unverified: Signature valid but key not added to platform
  • Invalid: Signature verification failed

The git wrapper automatically starts the agent, but you can start it manually:

Terminal window
ledger-gpg-agent --homedir ~/.gnupg-ledger --server --verbose &

Note: Use --server mode (not --daemon). The --daemon mode has issues.

Terminal window
# Check if agent is running
pgrep -f ledger-gpg-agent
# View agent logs
tail -f ~/.local/share/ledger-gpg-agent.log
tail -f ~/.local/share/ledger-gpg-agent.error.log
Terminal window
# Kill all GPG agents
killall ledger-gpg-agent gpg-agent
# Or kill specific process
pkill -f "ledger-gpg-agent.*--homedir.*\.gnupg-ledger"

The configuration includes a launchd service (macOS) that automatically starts ledger-gpg-agent on login:

# From nix/profiles/hardware-security.nix
launchd.agents.ledger-gpg-agent = {
enable = true;
config = {
ProgramArguments = [ "...ledger-gpg-agent wrapper..." ];
RunAtLoad = true;
};
};

Problem: Git commit fails with gpg: skipped "KEYID": No secret key

Solution:

  1. Check if agent is running:

    Terminal window
    pgrep -f ledger-gpg-agent
  2. Start the agent manually:

    Terminal window
    ledger-gpg-agent --homedir ~/.gnupg-ledger --server --verbose &
  3. Test signing:

    Terminal window
    echo "test" | gpg --homedir ~/.gnupg-ledger --clearsign

Problem: Agent starts but can’t communicate with Ledger

Solution:

  1. Ensure Ledger is ready:

    • Ledger unlocked (PIN entered)
    • SSH/GPG Agent app is open
    • Screen shows “SSH/GPG Agent is ready”
  2. Kill and restart agents:

    Terminal window
    killall ledger-gpg-agent gpg-agent
    ledger-gpg-agent --homedir ~/.gnupg-ledger --server --verbose &
  3. Check logs:

    Terminal window
    tail -f ~/.local/share/ledger-gpg-agent.error.log

Problem: Git isn’t using the Ledger wrapper

Solution:

Check configuration:

Terminal window
# Should show wrapper script path, not direct gnupg path
git config --get gpg.program
git config --get gpg.openpgp.program

If wrong, rebuild your configuration:

Terminal window
darwin-rebuild switch --flake .#wikigen-mac

Checklist:

  • Ledger connected via USB
  • Ledger unlocked (PIN entered)
  • SSH/GPG Agent app is open on device
  • Screen shows “SSH/GPG Agent is ready”
  • Agent process is running (pgrep -f ledger-gpg-agent)

Test:

Terminal window
echo "test" | gpg --homedir ~/.gnupg-ledger --clearsign

Problem: Agent doesn’t stay in background

Note: The --daemon mode for ledger-gpg-agent has issues. Always use --server mode with &:

Terminal window
ledger-gpg-agent --homedir ~/.gnupg-ledger --server --verbose &

Terminal window
# List public keys
gpg --homedir ~/.gnupg-ledger --list-keys
# List secret keys (will show Ledger keys)
gpg --homedir ~/.gnupg-ledger --list-secret-keys
# Show key fingerprint
gpg --homedir ~/.gnupg-ledger --list-keys --fingerprint
Terminal window
# ASCII armored format
gpg --homedir ~/.gnupg-ledger --armor --export luc@distorted.media
# To file
gpg --homedir ~/.gnupg-ledger --armor --export luc@distorted.media > public-key.asc
# Specific key by ID
gpg --homedir ~/.gnupg-ledger --armor --export D2A7EC63E350CC488197CB2ED369B07E00FB233E

If you need to regenerate the exact same key (using recovery seed):

Terminal window
ledger-gpg init "Luc Chartier <luc@distorted.media>" --time=0 --homedir ~/.gnupg-ledger

The --time=0 flag ensures the timestamp is deterministic, producing the same key.

  • Public keys: ~/.gnupg-ledger/pubring.kbx
  • Private keys: Stored on Ledger device (never on computer)
  • Trust database: ~/.gnupg-ledger/trustdb.gpg
  • Agent socket: ~/.gnupg-ledger/S.gpg-agent

Located in nix/profiles/hardware-security.nix, the wrapper:

  1. Checks if ledger-gpg-agent is running
  2. Starts the agent in --server mode if not running
  3. Waits 2 seconds for agent to initialize
  4. Calls GPG with --homedir ~/.gnupg-ledger flag
  5. Passes all arguments through to GPG
pkgs.writeShellScript "gpg-ledger" ''
# Ensure ledger-gpg-agent is running
if ! pgrep -f "ledger-gpg-agent.*--homedir.*\.gnupg-ledger" > /dev/null; then
PATH="${pkgs.gnupg}/bin:$PATH" ${pkgs.ledger-agent}/bin/ledger-gpg-agent \
--homedir $HOME/.gnupg-ledger --server --verbose &
sleep 2
fi
# Use --homedir flag instead of GNUPGHOME
exec ${pkgs.gnupg}/bin/gpg --homedir $HOME/.gnupg-ledger "$@"
''

Why Both gpg.program and gpg.openpgp.program?

Section titled “Why Both gpg.program and gpg.openpgp.program?”

Git checks gpg.openpgp.program first when gpg.format=openpgp (Home Manager default). We override both to ensure our wrapper is always used.

Set in nix/profiles/hardware-security.nix:

home.sessionVariables = {
GPG_TTY = "$(tty)";
SSH_AUTH_SOCK = "$(gpgconf --list-dirs agent-ssh-socket)";
};

Also set system-wide (for SOPS) in nix/modules/secrets/sops.nix:

environment.variables = {
GNUPGHOME = "/Users/wikigen/.gnupg-ledger";
};

  1. Private key never leaves device - Signing happens on Ledger
  2. Physical confirmation required - Button press for every operation
  3. Non-exportable - Keys cannot be copied or stolen from computer
  4. Tamper-proof - Signatures prove commit authenticity and integrity
  1. Recoverable from seed - Keys regenerated from 24-word recovery phrase
  2. Deterministic generation - Same seed = same key (with --time=0)
  1. Separate keyring - Uses ~/.gnupg-ledger to avoid conflicts
  2. Audit trail - All signed commits visible in Git history
  3. Platform verification - GitHub/GitLab verify signatures

What Ledger protects against:

  • ✅ Key extraction from computer
  • ✅ Malware stealing keys from disk
  • ✅ Unauthorized signing/commits
  • ✅ Key theft via remote attacks

What Ledger doesn’t protect against:

  • ❌ Physical theft of device (PIN provides basic protection)
  • ❌ Compromise if seed phrase is leaked
  • ❌ Social engineering (tricking user to sign malicious commits)
  • ❌ Side-channel attacks on device itself



Terminal window
# Start agent
ledger-gpg-agent --homedir ~/.gnupg-ledger --server --verbose &
# Check agent status
pgrep -f ledger-gpg-agent
# Make signed commit (automatic)
git commit -m "message"
# View signature
git log --show-signature -1
# Export public key
gpg --homedir ~/.gnupg-ledger --armor --export luc@distorted.media
# Test signing
echo "test" | gpg --homedir ~/.gnupg-ledger --clearsign
# List keys
gpg --homedir ~/.gnupg-ledger --list-keys
# Kill agents
killall ledger-gpg-agent gpg-agent

Happy signing! 🔐