Skip to content

docs: document how to do GPG signing when working from a secondary machine #11

@damienwebdev

Description

@damienwebdev

Below follows a Claude generated guide. It's not perfect and needs revision, I was able to partially follow it and get a working setup. I need to re-review this and document how this actually needs to work.


GPG Commit Signing in a Three-Hop Devcontainer Setup

The Problem

GPG agent forwarding was designed for a two-machine model:

You (laptop with keys + display) ──SSH──> Server (runs gpg, no keys)

The server forwards signing requests back to your laptop's agent. Your laptop
has a pinentry program (e.g., pinentry-mac) that pops up a dialog. You type
your passphrase. The signature is returned. Simple.

Our setup has three hops:

Mac (you sit here)  ──VS Code SSH──>  WSL/Linux (Docker host)  ──devcontainer──>  Container (you work here)

The VS Code Dev Containers extension runs on the Docker host (WSL). When it
sets up GPG forwarding, it runs gpgconf --list-dirs agent-extra-socket on
WSL
and forwards that socket into the container. It has no awareness of the
Mac behind it.

The result:

  • Container's S.gpg-agent → forwarded to → WSL's S.gpg-agent.extra
  • WSL is headless — no display, no TTY on the agent
  • The extra socket runs in GnuPG's "restricted mode," which blocks loopback
    pinentry (the workaround that would send the passphrase prompt back to your
    terminal)
  • Pinentry has nowhere to go. Signing fails.

The extension was designed for local Docker (two-layer: you + container) or
Codespaces (which bypasses GPG entirely by signing with GitHub's own key). A
remote Docker host with a developer on a third machine is an unsupported gap.

The Solution

Forward the Mac's GPG agent socket through all three hops so the container
ultimately talks to the Mac's agent, where pinentry-mac can prompt you:

Container gpg
  → socket forwarded by devcontainers extension →
WSL "extra socket" (actually Mac's agent, forwarded via SSH)
  → back to Mac's gpg-agent →
pinentry-mac pops up on your Mac screen
  → you type passphrase →
signature returned to container

Step-by-Step Setup

1. Mac: Install and configure GPG

Install GnuPG and pinentry-mac:

brew install gnupg pinentry-mac

If your GPG keys are currently on WSL, export them there and import on the Mac:

# On WSL — export
gpg --export-secret-keys --armor YOUR_KEY_ID > private-key.asc
gpg --export --armor YOUR_KEY_ID > public-key.asc
gpg --export-ownertrust > trust.txt

# On Mac — import
gpg --import private-key.asc
gpg --import public-key.asc
gpg --import-ownertrust trust.txt

# Clean up (don't leave private key files lying around)

Configure the Mac's gpg-agent (~/.gnupg/gpg-agent.conf):

pinentry-program /opt/homebrew/bin/pinentry-mac
default-cache-ttl 3600
max-cache-ttl 86400

Restart the agent:

gpgconf --kill gpg-agent
gpgconf --launch gpg-agent

Verify pinentry works locally:

echo "test" | gpg --clearsign

A macOS dialog should appear asking for your passphrase.

2. Mac: Note your socket paths

gpgconf --list-dirs agent-extra-socket

This will return something like:

/Users/yourname/.gnupg/S.gpg-agent.extra

Save this path — you'll need it in step 4.

3. WSL: Configure sshd to allow socket forwarding

On the WSL/Linux Docker host, edit /etc/ssh/sshd_config:

StreamLocalBindUnlink yes

This allows SSH to replace existing socket files when setting up remote
forwarding. Without this, the forwarding will fail if WSL's gpg-agent has
already created its own socket at that path.

Restart sshd:

sudo systemctl restart sshd

4. WSL: Note the extra socket path

gpgconf --list-dirs agent-extra-socket

This will return something like:

/home/damien/.gnupg/S.gpg-agent.extra

Save this path — you'll need it in step 5.

5. Mac: Configure SSH to forward GPG socket to WSL

Edit your SSH config on the Mac (~/.ssh/config):

Host your-wsl-host
    HostName <ip-or-hostname>
    User damien
    RemoteForward /home/damien/.gnupg/S.gpg-agent.extra /Users/yourname/.gnupg/S.gpg-agent.extra

Replace the socket paths with the actual values from steps 2 and 4.

This tells SSH: when I connect to WSL, forward WSL's extra socket back to my
Mac's extra socket. The devcontainers extension will then forward this into the
container, completing the chain.

6. WSL: Stop the local gpg-agent

The SSH RemoteForward will replace the socket file (thanks to
StreamLocalBindUnlink), but WSL's gpg-agent may recreate it. Either stop it:

gpgconf --kill gpg-agent

Or prevent it from starting by adding to ~/.bashrc or ~/.zshrc on WSL:

# Don't start gpg-agent on WSL — we forward from Mac
unset GPG_AGENT_INFO

Optionally, disable WSL's gpg-agent systemd unit if it exists:

systemctl --user disable gpg-agent.socket gpg-agent.service 2>/dev/null

7. WSL: Ensure the public keyring is available

The devcontainers extension copies pubring.kbx and trustdb.gpg from WSL
into the container. Since the keys now live on the Mac, WSL needs a copy of the
public keyring (not the private keys):

# On Mac — export public keyring
gpg --export --armor YOUR_KEY_ID > /tmp/public-key.asc
gpg --export-ownertrust > /tmp/trust.txt

# Copy to WSL (via scp or however you transfer files)
scp /tmp/public-key.asc /tmp/trust.txt your-wsl-host:~/

# On WSL — import
gpg --import ~/public-key.asc
gpg --import-ownertrust ~/trust.txt
rm ~/public-key.asc ~/trust.txt

8. Connect and test

  1. SSH into WSL from your Mac (or open VS Code Remote-SSH to your WSL host).
    The RemoteForward activates on connection.

  2. Open your devcontainer.

  3. In the container terminal:

# Verify the agent is reachable
gpg-connect-agent "getinfo version" /bye

# Test signing
echo "test" | gpg --clearsign

A pinentry-mac dialog should appear on your Mac. Type your passphrase.

  1. Test a commit:
git commit --allow-empty -S -m "test gpg signing"

Troubleshooting

"No agent running" or connection refused

The SSH RemoteForward may not be active. Check:

  • Is your VS Code SSH connection using the right SSH config entry?
  • Is StreamLocalBindUnlink yes set in WSL's sshd_config?
  • Is WSL's own gpg-agent recreating the socket? Kill it.

"Inappropriate ioctl for device"

The pinentry can't reach a display. Verify:

  • pinentry-mac is installed on the Mac: which pinentry-mac
  • Mac's gpg-agent.conf has pinentry-program /opt/homebrew/bin/pinentry-mac
  • Mac's agent is running: gpgconf --launch gpg-agent

"ERR 67109115 Forbidden" (restricted mode)

You're hitting the extra socket's restrictions. This is expected for admin
commands like updatestartuptty, but signing should still work through the
extra socket. If signing fails with this error, the socket forwarding may not
be connected to the Mac's agent.

Public key not found in container

The devcontainers extension copies the keyring from WSL. Make sure you imported
the public key on WSL (step 7). You can also verify inside the container:

gpg --list-keys

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions