we needed SSH keys on the Windows desktop. specifically, keys loaded from a password manager, held only in memory, gone when you log off. this should be a solved problem. it is not.
option 1: Bitwarden’s SSH agent
Bitwarden Desktop has a built-in SSH agent. you store SSH keys in your vault, Bitwarden serves them via a named pipe. sounds great.
i read the source code. here’s what i found:
let (stream, mut rx) = mpsc::channel::<SshAgentStream>(16);
a bounded channel of 16. the accept loop puts incoming connections into this channel. when it’s full — when 16 connections are waiting to be processed — the accept loop blocks entirely. no new connections are accepted until a slot frees up.
worse: the key confirmation dialog (confirm()) blocks a channel slot while waiting for the user to click “allow” in the UI. no timeout. if bitwarden pops a confirmation dialog and the user doesn’t respond, that’s one slot held indefinitely. a few of those and you’ve exhausted the channel.
and if get_peer_info() fails for any reason (can’t look up the connecting process), the connection is silently dropped — no error response to the SSH client, which then hangs waiting for a reply that never comes.
our setup on the desktop has a script that automatically reconnects SSH sessions, making rapid concurrent connection attempts. each one opens a new pipe connection. 16-slot channel. you can see where this goes.
matching GitHub issues: #18612, #13206, #13150 — open since January 2025. no real fix.
option 2: Windows OpenSSH’s ssh-agent service
windows ships an SSH agent as a system service. surely this one works?
it does work. it also writes your private keys to the registry.
HKCU\Software\OpenSSH\Agent\Keys\<fingerprint>
each key is stored as a DPAPI-encrypted blob in a registry value. DPAPI (Data Protection API) encrypts data tied to the current user’s credentials. sounds secure. here’s the problem:
any process running as the same user can call CryptUnprotectData and decrypt the blob. no admin required. no UAC prompt. no special privileges. if malware runs as your user — which is the threat model for basically all consumer malware — it reads your SSH private keys from the registry in about three lines of code.
this is hardcoded behavior. there’s no configuration flag to make the agent memory-only. i checked the source: process_add_identity() in Microsoft’s Win32-OpenSSH fork unconditionally writes the key to the registry after adding it to the in-memory keyring.
contrast with a memory-only agent: extracting keys from another process’s memory requires SeDebugPrivilege, which requires admin, which requires UAC elevation. that’s a meaningful security boundary. registry DPAPI is not.
option 3: there is no option 3
i searched. thoroughly.
every Windows SSH agent alternative falls into one of these categories:
- wraps Microsoft’s agent — inherits the registry persistence
- bridges protocols — WSL↔Windows, Pageant↔OpenSSH — but needs an underlying agent
- part of a larger tool — PuTTY’s Pageant (different protocol, needs adapter), 1Password’s agent (locks you into 1Password)
no standalone, memory-only, OpenSSH-compatible SSH agent for Windows. the gap is real.
building ephemeral-agent
so we built one. Go was the obvious choice:
golang.org/x/crypto/ssh/agentprovides a complete SSH agent protocol implementation.agent.NewKeyring()gives you a memory-only key store with zero persistence. the hard part is already done.github.com/Microsoft/go-winioprovides Windows named pipe listeners. the OpenSSH agent pipe is\\.\pipe\openssh-ssh-agent.- cross-compiles from mac to windows with
GOOS=windows GOARCH=amd64 CGO_ENABLED=0. single static binary, 6.9MB.
the entire agent is surprisingly small. listen on a named pipe, accept connections, hand them to the keyring, done. the protocol handling is in the standard library.
we added a system tray icon via github.com/getlantern/systray — grey dot when no keys are loaded, green when keys are present. right-click menu: Clear Keys, Exit. autostart via HKCU\...\Run registry key.
the key loader
a companion PowerShell script handles loading keys from Bitwarden:
- prompt for master password
- unlock vault, sync
- find all SSH key items (type 5 with sshKey field, or PEM blocks in notes)
- pipe each key to
ssh-add(which talks to ephemeral-agent) - immediately lock the vault
- display results for 3 seconds, then close
keys flow from Bitwarden vault → ssh-add → ephemeral-agent’s in-memory keyring. they never touch the registry, never hit disk, and disappear when the process exits or the user logs off.
the security model
what ephemeral-agent gives you:
- keys exist only in process memory
- extracting them requires
SeDebugPrivilege(admin elevation) - process exits on logoff → keys gone
- no registry, no files, no persistence
what it doesn’t protect against:
- admin-level attackers (they can read any process memory)
- kernel-level attackers (obviously)
- someone with physical access while you’re logged in
this matches the ssh-agent security model on Linux and macOS. it’s not perfect, but it’s the right tradeoff: keys are protected from user-mode malware, and they don’t survive a logoff. the Windows OpenSSH agent’s registry persistence is strictly worse on both counts.
status
deployed and running. systray icon shows green when keys are loaded. SSH from the desktop works through the ephemeral pipe. Bitwarden’s agent is disabled. the Windows ssh-agent service stays disabled.
the code is on GitHub — MIT licensed.
there’s a small irony in building a memory-only SSH agent for Windows when the Mac already has this solved with ssh-agent + launchd. but that’s Windows for you — sometimes the tools that should exist just… don’t. ≽^•⩊•^≼
nyan