https://github.com/pavlov-net/ssh-agent-proxy
Localhost HTTP signing proxy backed by any SSH agent — single Rust binary, no runtime dependencies
https://github.com/pavlov-net/ssh-agent-proxy
sandbox ssh-agent
Last synced: 4 days ago
JSON representation
Localhost HTTP signing proxy backed by any SSH agent — single Rust binary, no runtime dependencies
- Host: GitHub
- URL: https://github.com/pavlov-net/ssh-agent-proxy
- Owner: pavlov-net
- License: mit
- Created: 2026-04-09T17:11:48.000Z (18 days ago)
- Default Branch: main
- Last Pushed: 2026-04-20T02:42:04.000Z (8 days ago)
- Last Synced: 2026-04-20T04:32:34.429Z (8 days ago)
- Topics: sandbox, ssh-agent
- Language: Rust
- Homepage:
- Size: 118 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# ssh-agent-proxy
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.
## Why
Two problems compose badly:
1. **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.
2. **Sandboxed container runtimes like `docker sbx` can'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.
## Endpoints
- **`POST /sign`** — body is raw bytes to sign, response is an armored
`-----BEGIN SSH SIGNATURE-----` block with namespace `git`.
Byte-identical to `ssh-keygen -Y sign -n git` for deterministic
signature schemes (Ed25519 and RSA `rsa-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-populate `user.signingkey` so you don't have to bake a specific
key into the container image.
- **`GET /healthz`** — liveness probe.
## Backend / agent paths
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.
## Environment variables
| 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.
## Build
Requires Rust 1.85+ (edition 2024).
With make (Linux / macOS / WSL2):
```sh
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 test
```
On Windows (native, with Rust installed):
```powershell
cargo build --release
# binary at target\release\ssh-agent-proxy.exe
```
From WSL2 targeting Windows (requires `rustup target add x86_64-pc-windows-gnu`
and `apt install gcc-mingw-w64-x86-64`):
```sh
make build-windows
```
## Run it interactively
```sh
# 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:
```sh
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-----
```
## Run as a systemd user service (Linux / WSL2 / macOS)
### WSL2 one-time prerequisites
Skip if you're not on WSL2.
1. Enable systemd in `/etc/wsl.conf`:
```ini
[boot]
systemd=true
```
Then `wsl --shutdown` from PowerShell / cmd and re-open your shell.
2. Enable lingering so user services survive closing your last WSL
terminal:
```sh
sudo loginctl enable-linger "$USER"
```
### Install
```sh
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 logs
```
`install-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:
```sh
make uninstall-systemd # preserves ~/.config/ssh-agent-proxy/env
```
## Run on Windows
Install 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\Run` entry 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` (default `127.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` (default `git`)
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.
## Use it from a container
### Install the shim
```dockerfile
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
```sh
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 true
```
Note 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.
### Networking
The proxy binds `127.0.0.1:7221` on the host. Simplest container
networking:
```sh
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:
```sh
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`.
### Shim environment variables (container side)
| 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 |
## How the signing works under the hood
`src/sshsig.rs` is a from-scratch implementation of OpenSSH's
[SSHSIG wire format](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig)
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.
## Security notes
### Threat model
**Defends against:**
- Another unprivileged process on the same host scraping
`/proc/$pid/mem` or attaching via `ptrace` (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 sets
`LimitMEMLOCK=infinity` so 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=1` on Linux,
`NoNewPrivileges=true` in 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 enable
`SeDebugPrivilege` on 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 `/sign` or talk to the agent directly. Same trust boundary as
`ssh-agent`.
- Hardware attacks (cold boot, DMA, physical access).
- The internals of the ssh-agent process, wherever it is.
### Config hygiene
- On Linux/macOS, `~/.config/ssh-agent-proxy/env` should be 0600 and
under `ProtectHome=read-only` in 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%`.
### HTTP authentication
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`.
## Repo layout
| 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 |
## Tests
```sh
make check # cargo clippy + cargo test
```
21 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.