A tiny localhost HTTP proxy that forwards SSHSIG sign requests to a local ssh-agent. It exists so you can sign git commits from inside a container (or any sandbox that can't bind-mount Unix sockets) while the private key stays in whatever agent holds it on the host — 1Password Desktop, the stock OpenSSH agent, gpg-agent with SSH support, yubikey-agent, anything that speaks the agent protocol.
Two problems compose badly:
- SSH keys in 1Password Desktop on Windows live behind a named
pipe (
\\.\pipe\openssh-ssh-agent), not a Unix socket. A WSL2 process can't open the pipe directly without a Windows-side helper. - Sandboxed container runtimes like
docker sbxcan't bind-mount arbitrary Unix sockets from the host into the workload. Even on a pure Linux box with a normal ssh-agent socket, you can't just forward it into a sandbox the usual way.
The common answer is "run a signing HTTP oracle on the host that talks to the agent, and have the container hit it over HTTP" — every sandbox leaves outbound network open. This repo is that oracle.
┌──────────────────────┐ ┌────────────────────────────┐
│ host side │ │ container / sandbox │
│ │ │ │
agent ◄──┤ ssh-agent-proxy │ HTTP │ git commit -S │
│ :7221 /sign │◄──────────┤ gpg.ssh.program = │
│ /publickey │ │ ssh-agent-proxy-sign │
│ /healthz │ │ │
└──────────────────────┘ └────────────────────────────┘
The proxy holds no private key material of its own. Every /sign
and /publickey request opens a fresh connection to the configured
agent, lets the agent do the cryptographic work, then closes the
connection. Key rotation in the upstream agent takes effect on the
very next request, with no proxy restart.
POST /sign— body is raw bytes to sign, response is an armored-----BEGIN SSH SIGNATURE-----block with namespacegit. Byte-identical tossh-keygen -Y sign -n gitfor deterministic signature schemes (Ed25519 and RSArsa-sha2-512).GET /publickey— OpenSSH authorized_keys-format line for the key the proxy will sign with. The container-side shim uses this to auto-populateuser.signingkeyso you don't have to bake a specific key into the container image.GET /healthz— liveness probe.
The proxy dials whichever ssh-agent you point it at. Defaults per platform:
| Platform | Default agent path | Override env var |
|---|---|---|
| Linux / macOS | $SSH_AUTH_SOCK |
SSH_AGENT_PROXY_UPSTREAM |
| Windows | \\.\pipe\openssh-ssh-agent |
SSH_AGENT_PROXY_UPSTREAM |
On Linux 1Password Desktop typically exposes its socket at
~/.1password/agent.sock; on macOS
~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock;
on Windows the standard OpenSSH agent named pipe is where 1Password
(and the Windows OpenSSH service) both listen. If you're happy with
whatever SSH_AUTH_SOCK already points at, leave
SSH_AGENT_PROXY_UPSTREAM unset and the proxy honors it.
| Var | Default | Purpose |
|---|---|---|
SSH_AGENT_PROXY_ADDR |
127.0.0.1:7221 |
HTTP listen address |
SSH_AGENT_PROXY_NAMESPACE |
git |
SSHSIG namespace |
SSH_AGENT_PROXY_UPSTREAM |
(see above) | Upstream agent path |
SSH_AGENT_PROXY_PUBKEY |
unset | Literal authorized_keys line; if set, pin signing to this specific key from the agent |
SSH_AGENT_PROXY_PUBKEY_FILE |
unset | Path to a file containing the pubkey line (ignored if SSH_AGENT_PROXY_PUBKEY is set) |
If neither SSH_AGENT_PROXY_PUBKEY nor SSH_AGENT_PROXY_PUBKEY_FILE
is set, the proxy uses the first key the agent advertises.
Requires Rust 1.85+ (edition 2024).
With make (Linux / macOS / WSL2):
make build # target/release/ssh-agent-proxy
make build-windows # cross-compile to x86_64-pc-windows-gnu
make build-darwin # cross-compile to aarch64-apple-darwin
make build-all # all three
make install # install to ~/.local/bin (override BINDIR=…)
make check # cargo clippy + cargo testOn Windows (native, with Rust installed):
cargo build --release
# binary at target\release\ssh-agent-proxy.exeFrom WSL2 targeting Windows (requires rustup target add x86_64-pc-windows-gnu
and apt install gcc-mingw-w64-x86-64):
make build-windows# Point at your local ssh-agent and start the proxy
export SSH_AUTH_SOCK=$HOME/.1password/agent.sock # or whatever
./target/release/ssh-agent-proxy
# listening on 127.0.0.1:7221 (namespace "git")Quick smoke test from another shell:
curl -s http://127.0.0.1:7221/publickey
# ssh-ed25519 AAAA… user@host
printf 'hello\n' | curl -s --data-binary @- http://127.0.0.1:7221/sign
# -----BEGIN SSH SIGNATURE-----
# …
# -----END SSH SIGNATURE-----Skip if you're not on WSL2.
-
Enable systemd in
/etc/wsl.conf:[boot] systemd=true
Then
wsl --shutdownfrom PowerShell / cmd and re-open your shell. -
Enable lingering so user services survive closing your last WSL terminal:
sudo loginctl enable-linger "$USER"
make install-systemd # build + install + drop unit + drop env template
$EDITOR ~/.config/ssh-agent-proxy/env # point SSH_AUTH_SOCK or SSH_AGENT_PROXY_UPSTREAM
systemctl --user enable --now ssh-agent-proxy.service
make status # or: make logsinstall-systemd is idempotent and preserves the existing env file
on re-runs. The shipped unit enables a comprehensive systemd sandbox
(ProtectSystem=strict, ProtectHome=read-only, NoNewPrivileges,
LockPersonality, MemoryDenyWriteExecute, SystemCallFilter=@system-service,
LimitMEMLOCK=infinity, LimitCORE=0, and the rest of the usual
hardening set).
To remove:
make uninstall-systemd # preserves ~/.config/ssh-agent-proxy/envInstall the MSI from the latest release. The installer drops the
signed binary in %ProgramFiles%\ssh-agent-proxy\ and adds a Start
Menu shortcut.
The binary is a tray app. Double-click it (or let it auto-start after install) and a tray icon appears. Right-click gives you:
- Start at login (checked by default) — toggles an
HKCU\Software\Microsoft\Windows\CurrentVersion\Runentry so the proxy launches in your user session at logon. - Exit — shuts the HTTP server down and quits.
Configuration is via environment variables, same as Linux/macOS. Set them in your user environment (System Properties → Environment Variables → User variables) before the proxy starts:
SSH_AGENT_PROXY_ADDR(default127.0.0.1:7221)SSH_AGENT_PROXY_UPSTREAM(e.g.\\.\pipe\openssh-ssh-agent)SSH_AGENT_PROXY_PUBKEY_FILE(e.g.%USERPROFILE%\.ssh\git_signing.pub)SSH_AGENT_PROXY_NAMESPACE(defaultgit)
Logs go to %LOCALAPPDATA%\ssh-agent-proxy\tray.log.
For debugging, run ssh-agent-proxy.exe --console from a terminal;
log output appears on stderr and the tray icon is suppressed.
COPY scripts/ssh-agent-proxy-sign.sh /usr/local/bin/ssh-agent-proxy-sign
RUN chmod +x /usr/local/bin/ssh-agent-proxy-sign && \
apt-get update && apt-get install -y --no-install-recommends \
curl openssh-client ca-certificates && \
rm -rf /var/lib/apt/lists/*openssh-client is needed only if you also want to verify
signatures inside the container — the shim delegates -Y verify /
-Y check-novalidate to the real ssh-keygen. If you only sign, you
can drop it.
git config --global gpg.format ssh
git config --global gpg.ssh.program /usr/local/bin/ssh-agent-proxy-sign
git config --global user.signingkey ~/.cache/ssh-agent-proxy-sign/signing.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign trueNote that user.signingkey points at a path that doesn't exist
yet. The shim auto-populates it from the proxy's /publickey
endpoint on first use, so the container never needs to bake in a
specific public key. To pick up a rotated key, rm the cache file
and it refreshes on the next commit.
The proxy binds 127.0.0.1:7221 on the host. Simplest container
networking:
docker run --network host …If your runtime can't do --network host (e.g. docker sbx), bind
the proxy to 0.0.0.0:7221 with
SSH_AGENT_PROXY_ADDR=0.0.0.0:7221 and give the container a
host-gateway hop:
docker run --add-host=host.docker.internal:host-gateway \
-e SSH_AGENT_PROXY_URL=http://host.docker.internal:7221/sign \
…Be aware that binding 0.0.0.0 exposes the signing endpoint to
anything that can reach the host interface. The trust boundary is
"any local process as your user can request signatures", same as
ssh-agent.
| Var | Default | Purpose |
|---|---|---|
SSH_AGENT_PROXY_URL |
http://127.0.0.1:7221/sign |
Sign endpoint URL |
SSH_AGENT_PROXY_PUBKEY_URL |
derived from SSH_AGENT_PROXY_URL |
Public-key endpoint URL |
SSH_AGENT_PROXY_CURL |
curl |
Override the curl binary |
src/sshsig.rs is a from-scratch implementation of OpenSSH's
SSHSIG wire format
plus the 70-column PEM-like armor. Given any sshsig::Signer, it
produces an armored signature byte-identical to what ssh-keygen -Y sign -n git would have produced for deterministic signature schemes
(Ed25519, and RSA with rsa-sha2-512 / PKCS#1 v1.5). There are
byte-equality tests against ssh-keygen in the test suite.
src/agent.rs implements a minimal SSH agent protocol client
(REQUEST_IDENTITIES + SIGN_REQUEST only). src/agent_source.rs
wraps it into an AgentSource that dials the agent fresh per
request, selects the configured key, and returns a Signer. For RSA
keys we force the rsa-sha2-512 flag and verify the agent honored
it — a misbehaving agent that tried to downgrade to SHA-1 would be
rejected rather than returning a signature that modern verifiers
won't accept. This check applies to all key types.
Defends against:
- Another unprivileged process on the same host scraping
/proc/$pid/memor attaching viaptrace(Linux:prctl(PR_SET_DUMPABLE, 0); macOS:ptrace(PT_DENY_ATTACH); Windows: process mitigation policies + strict handle checks). - Transient buffers ending up in a swap file (Linux and macOS:
mlockall(MCL_CURRENT|MCL_FUTURE); the systemd user unit setsLimitMEMLOCK=infinityso this doesn't silently fall back to "swap protection off"). - Transient buffers ending up in a core dump (Linux and macOS:
RLIMIT_CORE=0+PR_SET_DUMPABLE=0; systemd unit:LimitCORE=0; Windows:SetErrorMode+ crash-dump suppression). - Re-gaining privileges on exec (
PR_SET_NO_NEW_PRIVS=1on Linux,NoNewPrivileges=truein the systemd unit, no setuid-alike on the other platforms). - Rotation drift. The proxy does not cache the signer across requests. Rotate the key in the upstream agent and the very next sign uses the new key.
- The container seeing the private key. It doesn't, ever. The container sees only signatures and (optionally) the public key.
Does NOT defend against:
- Root on the same host. Root can read
/proc/$pid/mem, load a kernel module, use EndpointSecurity on macOS, or enableSeDebugPrivilegeon Windows. Userspace mitigations don't hold against the kernel. - A compromised upstream agent. The proxy trusts the agent to return honest signatures; we sanity-check the returned signature format against what we requested, but we can't tell whether the agent is signing with the right private key.
- Other processes running as your own user. Any of them can already
call
/signor talk to the agent directly. Same trust boundary asssh-agent. - Hardware attacks (cold boot, DMA, physical access).
- The internals of the ssh-agent process, wherever it is.
- On Linux/macOS,
~/.config/ssh-agent-proxy/envshould be 0600 and underProtectHome=read-onlyin the systemd unit. Don't check it into dotfiles git. - On Windows, configure the proxy via per-user environment variables (System Properties → Environment Variables → User variables). The tray app inherits them at launch. Machine-wide environment variables work too but expose the config to every account on the host.
- Tray logs land in
%LOCALAPPDATA%\ssh-agent-proxy\tray.log, which keeps them out of the world-readable%ProgramData%.
There is none. Any local process running as your user can call
/sign and get signatures, just like any local process can use
ssh-agent. For stronger isolation, bind the proxy to a Unix socket
you bind-mount selectively into containers (requires a small patch —
SSH_AGENT_PROXY_ADDR is TCP-only today) or put a bearer token in
front of /sign and /publickey.
| Path | What |
|---|---|
src/main.rs |
Config loading, HTTP server (axum), signal handling |
src/agent.rs |
Minimal SSH agent protocol client (LIST + SIGN) |
src/agent_source.rs |
AgentSource + AgentBackedSigner |
src/sshsig.rs |
SSHSIG wire format + OpenSSH armor |
src/wire.rs |
Shared SSH wire-format primitives |
src/config.rs |
Environment variable configuration |
src/server.rs |
axum HTTP handlers (/sign, /publickey, /healthz) |
src/dialer_unix.rs |
Unix domain socket dialer |
src/dialer_windows.rs |
Windows named-pipe dialer |
src/hardening_{linux,macos,windows}.rs |
Per-platform process hardening |
src/tray_windows.rs |
Windows tray icon + menu + message loop |
src/autostart_windows.rs |
HKCU Run-key management for "Start at login" |
wix/ssh-agent-proxy.wxs |
WiX v4+ installer definition |
assets/icon.ico |
Windows application icon (embedded via build.rs) |
scripts/ssh-agent-proxy-sign.sh |
Container-side gpg.ssh.program shim |
contrib/systemd/ssh-agent-proxy.service |
systemd user unit |
contrib/systemd/env.example |
EnvironmentFile= template |
make check # cargo clippy + cargo test21 tests cover the SSH wire-format primitives, SSHSIG byte-equality
against real ssh-keygen (Ed25519 and RSA), agent protocol parsing,
key selection logic, and check-novalidate verification. Tests that
shell out to ssh-keygen skip themselves when it's not installed.