SSH Agent¶
dotvault can expose an SSH agent backed by your live Vault token. Because the daemon already holds a renewing token and reads your per-user KVv2 secrets, it can answer signing requests without ever writing a private key to disk.
Two key sources are configured under agent.keys[], and a third — the SSH
agent relay — is always present unless you turn it off:
- KV keys — raw key pairs discovered under a KV path prefix (the same
public_key/private_keyschema the SSH enrolment engine writes). - Vault-CA certificates — short-lived certificates minted on demand by a Vault SSH CA secrets engine. The private key is generated in memory and never persisted.
- The SSH agent relay (implicit, on by default) — the SSH agents you already run:
your own
ssh-agent, a keyring daemon, gpg-agent, a password manager's agent, the Windows OpenSSH agent, Pageant) that dotvault sits in front of and proxies to. dotvault never stores or reads their key material — it forwards the agent protocol — so you keep using legacy on-disk keys that already live in your personal agent (the static keys you've registered with GitHub, Bitbucket Server, etc.) alongside dotvault's Vault-backed keys, from one socket. It auto-detects them and is always tried last, after your configured key sources — you do not declare it, andagent.relay.enabled: falseis the one way to switch it off.
dotvault's own identities are read-only, mirroring its one-way sync: they
come from Vault and are never added, removed, or locked by a client. Because
the relay is on by default, ssh-add, ssh-add -d and ssh-add -D are
forwarded to the agent underneath rather than
refused — they do to your own agent exactly what they would have done had you
addressed it directly. With relay.enabled: false there is nowhere to forward
them, so the whole agent is read-only and all three return an error.
Cert mode is the recommended direction
With Vault-CA certificates the private key never lands on disk, rotation is
automatic, and remote hosts trust only the CA public key
(TrustedUserCAKeys) rather than per-user authorized_keys. The KV-key and
file-sync paths remain supported for hosts where distributing the CA trust
is impractical, but prefer cert mode where you can.
Configuration¶
Add an agent: section. It is disabled by default.
agent:
enabled: true
relay:
enabled: true # default; the only way to disable the relay
socket: "" # Unix; empty = auto-detect (recommended)
pipe: "" # Windows; empty = auto-detect (recommended)
unix:
path: "" # default: $XDG_RUNTIME_DIR/dotvault/agent.sock
windows:
pipe: "\\\\.\\pipe\\dotvault-agent"
putty: true # also serve the Pageant-convention pipe (default true)
keys:
- source: kv
path_prefix: "ssh/" # kv/data/users/<you>/ssh/*
- source: vault-ca
mount: "ssh-client-signer"
role: "dotvault-user"
principals: ["{{.vault_username}}"]
ttl: "15m"
ephemeral_key: true
| Field | Description | Default |
|---|---|---|
agent.enabled |
Master switch for the agent listener | false |
agent.unix.path |
Unix socket path | per-user runtime path |
agent.windows.pipe |
Windows pipe name | \\.\pipe\dotvault-agent |
agent.windows.putty |
Also serve a Pageant-convention pipe (Windows) | true |
agent.relay.enabled |
Proxy to the SSH agents you already run | true |
agent.relay.socket |
Pin the relay to one Unix socket instead of detecting | (empty = auto-detect) |
agent.relay.pipe |
Pin the relay to one Windows pipe instead of detecting | (empty = auto-detect) |
agent.keys[] |
Ordered list of Vault-backed key sources (see below) | — |
On Windows, the entire agent section can be deployed via Group Policy / the
registry under HKLM\SOFTWARE\Policies\goodtune\dotvault\Agent instead of YAML
— including the ordered keys[] list, which is stored as numbered subkeys
(Agent\Keys\0, \1, …). Author the registry values directly, or generate them
from a YAML file with dotvault reg-import config.yaml (and recover YAML from a
.reg with dotvault reg-export). When the registry policy keys exist, they
take precedence and the YAML file is ignored.
KV source¶
source: kv with an optional path_prefix, resolved under
kv/data/users/<you>/. Every secret beneath the prefix is treated as a key
pair (public_key in authorized-keys form, private_key as an OpenSSH PEM).
Keys are discovered, not declared — a secret appearing or disappearing in
Vault changes the agent's identities on the next ssh-add -l without a restart.
Passphrases and KV keys¶
A passphrase protects a private key at rest on disk. In agent mode the key
is never written to a filesystem — it lives encrypted at rest in Vault, gated by
your token policy, and is read and signed with in-process — so the passphrase
is redundant here, and the headless daemon has no way to prompt to decrypt one.
Enrol KV keys destined for the agent with passphrase: unsafe: the name is a
misnomer in this context, because Vault is the at-rest protection. The standing
assumption is that you never exfiltrate the secret to disk (no parallel
file-sync rule for the same key, no vault kv get … > id_ed25519); if you do,
you reintroduce the at-rest exposure a passphrase would have covered, so encrypt
it then — see Choosing a mode.
The agent therefore rejects a passphrase-encrypted KV key at signing time rather than silently failing. Store agent keys unencrypted in Vault, or prefer cert mode (no key material at all).
Vault-CA source¶
source: vault-ca with the SSH CA secrets-engine mount, a role, templated
principals (e.g. {{.vault_username}}), a ttl, and ephemeral_key: true.
dotvault generates an in-memory key pair at startup and requests a certificate
from Vault at signing time. Certificates are cached until shortly before expiry
and transparently re-minted on the next request — including over a forwarded
agent connection, so long-lived forwarded session chains keep working.
The ssh-add -l comment for a vault-ca identity is
vault-ca:<role> - by dotvault for <user>, naming both the configured role
and the local OS user the daemon runs as — the same value
{{.vault_username}} expands to. That is handy when comparing identities
across more than one user's agent (e.g. a forwarded connection, or a shared
host running multiple per-user daemons). Note the comment can contain
spaces, since a Windows local account name can, so don't split
ssh-add -l output on whitespace to recover it.
The SSH agent relay¶
The relay puts dotvault in front of the SSH agents you already run and
proxies the agent protocol to them. It is not something you configure into
agent.keys[] — it is implicit, on whenever the agent is, and always tried
last, after every key source you did configure.
Last is deliberate: an ssh client works down the advertised list against the
server's MaxAuthTries budget, so dotvault's own identities get the first
attempts and the shadowed agents fill in behind them.
On by default is also deliberate. "Point every SSH client at dotvault once and
leave it there" only holds if the keys you already had keep working without you
having to ask; an arrangement that silently drops half your keys unless you
found the right config stanza is not one you can adopt wholesale. So the single
supported way to not have it is to say so — agent.relay.enabled: false.
This is what keeps legacy keys working: a static key registered with a service that can't take a short-lived cert, a key in a password manager's agent, a Secure Enclave key — all served from the same socket as your Vault-backed ones.
dotvault is a pure proxy here: it never stores, reads, or persists the upstream's private keys, and it dials a fresh connection per request so an upstream agent can come and go without a dotvault restart.
Auto-detection (the default)¶
Leave relay.socket/relay.pipe empty — the recommended setting — and
dotvault finds your agents itself:
On every identity refresh dotvault re-scans the well-known agent locations for the platform and proxies to every one that is a socket owned by your own account. Because the scan is repeated rather than resolved once at startup, an agent you start, restart, or forward in mid-session is picked up on the next request — no config change, no daemon restart.
What it looks at, in priority order:
| Order | Unix | Windows |
|---|---|---|
| 1 | $SSH_AUTH_SOCK |
$SSH_AUTH_SOCK (Git-for-Windows / WSL set this) |
| 2 | $XDG_RUNTIME_DIR/ssh-agent.socket (systemd ssh-agent.service) |
\\.\pipe\openssh-ssh-agent (OpenSSH agent service) |
| 3 | $XDG_RUNTIME_DIR/gcr/ssh, .../keyring/ssh (GNOME keyring) |
the Pageant-convention pipe |
| 4 | $XDG_RUNTIME_DIR/gnupg/S.gpg-agent.ssh, ~/.gnupg/S.gpg-agent.ssh |
— |
| 5 | ~/.1password/agent.sock; on macOS 1Password's group container and Secretive |
— |
| 6 | /tmp/ssh-*/agent.* (a plain ssh-agent fork); on macOS the launchd socket and $TMPDIR equivalents |
— |
On Linux the runtime-dir entries are also looked for under /run/user/<uid>
when XDG_RUNTIME_DIR is unset, which is the usual state for a daemon started
outside a login session.
Two things bound the scan:
-
Ownership. On Linux and macOS every connection to a discovered agent is checked against the peer's uid (
SO_PEERCRED/LOCAL_PEERCRED) — the kernel's answer about the process on the other end, not about the path it was reached through. That distinction matters because paths can be swapped: a candidate under the globbed/tmppatterns could otherwise be pointed at your real agent to pass a path check and re-pointed before the dial. Since mutations are forwarded (see below), the endpoint that won that race would receive the private key from yourssh-add. A path-level socket-and-owner check still runs first as a cheap filter, and it follows symlinks — the tmux~/.ssh/ssh_auth_sockconvention and 1Password's documented symlink on macOS are both symlinks, and refusing to follow them would hide the very agent you most want found.Only Linux and macOS have the peer check
Every other platform falls back to the path-level socket-and-owner check alone, which is weaker: it follows symlinks, so it establishes who owns the target now and cannot rule out the path being re-pointed before the dial. That covers Windows and also the other Unix platforms dotvault can be built for (the BSDs, illumos) — supported builds are Linux, macOS and Windows, but if you build elsewhere, this is what you get until a
peerUIDimplementation is added for it. On any multi-user host in that group, the honest advice isrelay.enabled: false— pinningrelay.socket/relay.pipefixes which endpoint is dialled but verifies nothing about who answers, and on Unix it also switches off the peer check auto-detection would have performed (the daemon logs a WARN when you pin one, for that reason).Windows additionally has no peer check available — a named pipe carries no owner a caller can read without opening it and querying its security descriptor — so detection there is a short fixed list. Be clear about what that does and does not buy: pipes are first-creator-wins, so a local user who creates
\\.\pipe\openssh-ssh-agentbefore the OpenSSH agent service starts owns that name for the boot, and the Pageant name's per-boot hash is derived withCryptProtectMemory(CROSS_PROCESS), which any process on the machine can reverse. This is the same trust model Windows OpenSSH's ownssh.exeand every PuTTY client already operate under when they dial those names — dotvault inherits that exposure rather than widening it. The daemon logs a WARN at startup saying so, because the relay is on by default andssh-addthrough it deposits a private key at whatever answers.Pinning
relay.pipeis not a mitigation for this. Naming the pipe yourself decides which endpoint is dialled, never who is listening on it — a squatted\\.\pipe\openssh-ssh-agentis reached identically whether the name came from detection or from your config. On a multi-user Windows host the control isrelay.enabled: false.- Never itself. dotvault refuses to delegate to an endpoint it serves,
which would loop
List/Signback into the daemon forever. It checks the paths and asks each candidate over the wire whether it is this daemon (a private agent extension). The second check is the one that matters in practice: once you setSSH_AUTH_SOCKto dotvault — the whole point — the top candidate is dotvault, often reached by a symlink, bind mount, or forwarded socket that no path comparison would catch.
- Never itself. dotvault refuses to delegate to an endpoint it serves,
which would loop
If several agents advertise the same key it is listed once, attributed to the
first, so a client doesn't burn two of a server's MaxAuthTries attempts on
one key. If one agent is unreachable the others still answer. If none is
running, the source simply contributes nothing — that is not an error.
The endpoints currently being shadowed are reported per source as upstreams
on GET /api/v1/status — the machine-readable answer to "what did detection
actually find?". The field distinguishes three cases, because "found nothing"
and "reports no upstreams at all" are different answers: it is absent on a
source with no upstreams to report, [] on a relay that found no agent to
shadow, and a list of endpoints otherwise. Key off the field rather than the
source's type, which does not imply it — a relay that failed to construct is
reported with type: agent and no upstreams. The endpoints are not rendered
in the web UI today; dotvault status lists the identities being served but
not which agent each came from.
Turning it off, or pinning one endpoint¶
Turning it off leaves the agent with only what you configured, so
relay.enabled: false needs at least one agent.keys[] entry — an agent that
can serve nothing is refused at config load rather than started as a listener
that only ever answers "no identities":
agent:
enabled: true
relay:
enabled: false # no relay at all
keys:
- source: kv
path_prefix: "ssh/" # required: nothing else is left to serve
Pinning keeps the relay but stops it detecting:
agent:
enabled: true
relay:
socket: /run/user/{{.uid}}/ssh-agent.socket # Unix: pin one agent
pipe: \\.\pipe\openssh-ssh-agent # Windows: pin one agent
| Field | Platform | Description | Default |
|---|---|---|---|
agent.relay.enabled |
all | Proxy to the agents you already run | true |
agent.relay.socket |
Unix | Pin the relay to this socket | (empty = auto-detect) |
agent.relay.pipe |
Windows | Pin the relay to this named pipe | (empty = auto-detect) |
Naming an endpoint disables detection for that platform and pins the relay to exactly that one. It is the escape hatch for an agent at a path detection doesn't know — it is not a security control, and it is not the answer on a multi-user host (see the warning above): it decides which endpoint is dialled, not who answers, and on Unix it gives up the peer-ownership check detection performs. The daemon logs a WARN when you pin one.
Like windows.putty, all three are inert rather than rejected when
agent.enabled is false, so you can stage a config before switching the agent
on.
Only the field matching the running platform is consulted, so a single config
can carry both for a mixed fleet. Both accept {{.username}} and {{.uid}}
template variables, so a fleet-wide config can resolve to each user's own
agent (e.g. relay.socket: "/run/user/{{.uid}}/ssh-agent.socket").
{{.username}} is the bare OS account name; {{.uid}} is the numeric UID on
Unix (and the user's SID on Windows, where it is rarely useful in a pipe
name). A mis-typed variable (e.g. {{.user}}) is rejected when the source is
constructed, not silently left in the path. A leading ~ in a socket path is
expanded to your home directory.
An endpoint that can't be resolved — a bad template, or a path equal to dotvault's own socket — becomes an error reported in status; the other sources keep working.
Adding keys through dotvault¶
Because dotvault sits in front of your agent rather than beside it, the mutating agent operations are forwarded upstream instead of refused:
export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/dotvault/agent.sock"
ssh-add ~/.ssh/id_ed25519 # lands in your own agent, via dotvault
ssh-add -l # lists it alongside dotvault's Vault-backed keys
ssh-add -d ~/.ssh/id_ed25519 # removes it from your agent
ssh-add -D # clears your agent (not dotvault's Vault keys)
ssh-add -x / -X # locks / unlocks your agent
dotvault stores nothing on this path either: the key goes straight out over the upstream connection and no copy is retained. Details worth knowing:
ssh-addtargets one agent, not all of them. The key goes to the most-preferred endpoint —$SSH_AUTH_SOCKwhen it names a real upstream, otherwise the first discovered. Copying a private key into every agent on the machine is not what you asked for and not something you could easily undo.-D,-x,-Xare agent-wide and do address every shadowed agent, which is what a client locking "the agent" means.- dotvault's own identities are untouched. They come from Vault and would
return regardless, so
ssh-add -dagainst a Vault-backed key is refused rather than silently doing nothing. - With
relay.enabled: falsethe whole agent is read-only: there is nowhere to forward a mutation to, sossh-add,ssh-add -d,-D,-xand-Xall return an error. That is the only configuration in which they do. - No Vault token is needed. These operations touch no Vault-backed source,
so
ssh-addworks against a daemon that hasn't authenticated yet. The same is true of listing and signing upstream keys: a daemon that cannot reach Vault still serves the agents it shadows, so an outage doesn't take your legacy keys down with the Vault-backed ones. - Check where
$SSH_AUTH_SOCKpoints before adding a key.ssh-addtargets the most-preferred endpoint, which is$SSH_AUTH_SOCKwhen it names a real upstream — and on a host you reached withssh -A, that is the forwarded agent on your workstation, so the key would leave this machine.dotvault statusand theupstreamsfield show what is being shadowed. ssh-add -xsends the passphrase to every shadowed agent. Locking is agent-wide by definition, so with several agents detected the one passphrase reaches all of them.
Agent extensions are deliberately not proxied. Some are connection-scoped by
design — OpenSSH's session-bind@openssh.com binds the client's own
connection — and answering one from a proxied connection to a different agent
would be a lie about what was bound. Clients treat the refusal as "unsupported"
and carry on.
Pointing clients at the agent¶
dotvault claims its own endpoint everywhere and never sets SSH_AUTH_SOCK
or any PuTTY registry value on your behalf — wiring clients to it is the
integration step, left to you (or your fleet tooling).
- OpenSSH (all platforms): set
SSH_AUTH_SOCKto the socket path (Unix) or pipe name (Windows). Windows OpenSSH honoursSSH_AUTH_SOCKpointing at a named pipe.
export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/dotvault/agent.sock"
ssh-add -l # list the identities dotvault is serving
!!! tip "Surviving daemon restarts (Linux)"
The packaged `dotvault-agent.socket` unit (optional, not enabled by default) lets systemd bind this socket and hold the fd across daemon restarts, so an `ssh` launched mid-restart queues briefly instead of failing. The queue is bounded by startup, not by authentication: the daemon serves the agent before it holds a Vault token (see [Before the daemon has authenticated](#before-the-daemon-has-authenticated)). `agent.enabled` remains required, and under activation the unit's `ListenStream=` path wins over `agent.unix.path`. See [Socket activation](../admin/deployment.md#socket-activation-optional).
- PuTTY / Pageant (Windows): modern PuTTY-family clients (PuTTY 0.71+,
WinSCP, FileZilla, …) locate Pageant over a named pipe whose name follows a
fixed convention —
\\.\pipe\pageant.<user>.<hash>— that they compute themselves and cannot be told to ignore. So that those clients find the agent with no configuration at all, dotvault serves a second listener on exactly that pipe wheneveragent.windows.puttyis true (the default). A named pipe carries a single name, so this is a parallel listener over the same backend, not an alias ofagent.windows.pipe. Both pipes serve the identical agent protocol. Setputty: falseto serve onlyagent.windows.pipe(e.g. when a separate Pageant is already running and you don't want dotvault to claim that name). The option only takes effect whenagent.enabledis true and is a no-op off Windows.
Clients that let you point at an explicit pipe (or Windows OpenSSH via
SSH_AUTH_SOCK) can still target \\.\pipe\dotvault-agent directly.
The Windows pipe(s) are created with a security descriptor granting access only
to the owning user and LocalSystem; the Unix socket is created 0600 in a
0700 directory. Only you can connect either way — the equivalent of
dotvault's 0600 invariant on its managed files.
Status¶
When the agent is enabled, dotvault status connects to the running daemon's
socket / pipe and lists the identities it is actually serving — the ssh-add -l
equivalent, spoken over the agent protocol. Because it queries the live daemon
rather than re-deriving anything from config, the output reflects exactly what
the agent offers: the keys currently discoverable in Vault and, for cert
sources, the daemon's cached certificate with its true remaining validity.
dotvault status is a read-only client here — it never creates the endpoint.
The same identities appear on the web dashboard, parallel to the per-rule sync state. The dashboard additionally groups them by source and shows per-source resolution errors (an unknown engine, a Vault read failure, a missing CA role) — detail the CLI can't show, because it lists identities over the agent protocol, which carries no notion of which configured source produced each one. For "why is this source not resolving?", consult the dashboard.
$ dotvault status
...
SSH Agent:
endpoint: /run/user/1000/dotvault/agent.sock
SHA256:… users/alice/ssh/laptop
SHA256:… vault-ca:dotvault-user - by dotvault for alice (cert, expires 2026-05-30T12:15:00Z)
Because the agent is only relevant when configured, dotvault status consults
the endpoint only when agent.enabled is set. A failure to reach it is
reported as unexpected — it means the daemon isn't running, or is not yet
serving this endpoint:
$ dotvault status
...
SSH Agent:
endpoint: /run/user/1000/dotvault/agent.sock
unreachable: dial unix /run/user/1000/dotvault/agent.sock: connect: no such file or directory
(agent is enabled but the daemon is not serving this endpoint — is `dotvault run` active?)
A daemon that is serving but cannot resolve any identity right now reports
that separately, because the two send you looking in completely different
places. The usual cause is a vault-ca source unable to mint for a moment,
often while the daemon replaces its own Vault token — check the per-source
errors on the web dashboard, or simply retry:
$ dotvault status
...
SSH Agent:
endpoint: /run/user/1000/dotvault/agent.sock
serving, but no identities could be resolved: agent could not list identities: ssh agent: ca: mint certificate: permission denied
(check the per-source errors on the web dashboard, or retry — a source may be mid-recovery)
Note this is distinct from the empty list below: an empty list means the agent has nothing to offer and says so cleanly, where this means it could not find out.
Before the daemon has authenticated¶
The daemon starts serving the agent early, before it obtains a Vault token,
so a client always gets an answer. Until a token arrives the agent has no
identities to offer, which dotvault status reports as:
$ dotvault status
Auth: not authenticated (no local token; no peer socket holds a token)
...
SSH Agent:
endpoint: /run/user/1000/dotvault/agent.sock
(no identities loaded — the daemon holds no Vault token yet, or no configured key source resolved one)
(A source that failed is not folded into this line — that is the "serving, but no identities could be resolved" case above.)
ssh sees the same empty list and moves straight on to its next
authentication method. A signing request in that window is refused rather than
held — the agent protocol carries only an opaque failure, so the client reports
something like agent refused operation and the reason appears in the daemon's
own log:
Fix the Auth: line — run dotvault login, or make a peer socket reachable —
and the identities appear without restarting anything.
This matters most under socket activation,
where systemd binds the socket at boot and the kernel completes connect()
into its backlog whether or not the daemon is accepting yet. Serving early
keeps that queue to the length of startup itself. A host that can never obtain
a token — nothing local and no peer to borrow from — would otherwise leave
every ssh and every dotvault status blocked indefinitely on a connection
that had been made and would never be read.
Server-side prerequisite for cert mode¶
Each host you connect to trusts the Vault SSH CA by pointing sshd at the CA
public key:
The CA public key comes from the SSH CA secrets engine
(<mount>/config/ca, public_key field). Distributing that one file is the
entire server-side cost of cert mode and is handled by your existing fleet
config tooling (Nix/Ansible/etc.).
Security notes¶
Agent forwarding exposes a signing oracle
Anyone with root on a host you forward your agent to can use the forwarded
socket to sign as you for the life of the session (they cannot extract the
key). Cert mode with short TTLs and scoped valid_principals bounds the
blast radius. ProxyJump avoids forwarding entirely where topology allows
and is the preferred pattern.
- Token-refresh interaction. If the Vault token is being replaced when a request arrives, the agent blocks briefly on the lifecycle manager rather than failing, then proceeds once a usable token is available (up to a bounded timeout). This covers listing as well as signing: a client asks the agent what identities it has before choosing a key, so rebuilding that list from a half-replaced token is where a connection is actually lost. It also covers the replacements that succeed — a certificate-auth daemon renewing its own token unattended holds the gate for the few hundred milliseconds the mint and login take, so callers wait it out instead of racing it. A listing that needs no Vault call is still answered immediately: a cached list inside its window, or the empty list the daemon owes before it has authenticated (see "Before the daemon has authenticated" above), never waits.
- A source that errors is not silently empty. With several
agent.keys[]sources configured, one that fails to list is skipped and the rest are still advertised. If every source fails, the agent reports an error rather than an empty list, because "the credential source hit a transient problem" and "no keys are configured" call for opposite responses from a client — the first is worth retrying in a moment, the second is not. A listing taken while any source was failing is also not cached, so a retry sees the source the moment it recovers rather than waiting out the cache window. - Concurrency. The backend is safe for concurrent use — two clients may
request signatures simultaneously, and identity listings are cached for a few
seconds to avoid hammering Vault on repeated
ssh-add -l. - Per-source isolation on signing. With multiple
agent.keys[]sources configured, a source that currently can't produce a signature (e.g. avault-carole that doesn't resolve under your active Vault auth method) never blocks signing for a key owned by a different, healthy source. Ifsshfails withagent refused operationeven thoughssh-add -Llists a valid, non-expired identity, checkdotvault statusor the web dashboard for a per-source error against one of your other configured sources.