A lightweight SSH agent for time-bound, human-approved sudo.
Instead of typing your password for every sudo on a remote server, or
trusting a long-lived key forever, sudo (via pam_ssh_agent_auth) asks
this agent to sign a challenge. The agent signs only if:
- the key is still within its time-to-live (TTL), and
- you approve that specific request with a yes/no prompt.
Warning
Experimental. This is early software that hasn't been audited. It works end to end, but expect rough edges and breaking changes. Keep password authentication as a fallback (see Server setup). Linux only.
your machine server
──────────── ──────
ssh-add ─► control.sock ─┐ sudo
▼ └─ pam_ssh_agent_auth
sudo-agent: holds keys until │ "sign this challenge"
their TTL ends, asks "Allow? [y/N]" │
▲ │
└─ agent.sock ◄── forwarded ◄────┘
- Passphrase once. It's asked for once, when the key is loaded (by
ssh-add, or by the agent for--key). After that, each signature only needs ay. - TTL inside the agent. A key's TTL starts when it's loaded. Keys are
dropped (and zeroized) when their time is up. The clock keeps counting
while the machine is suspended.
ssh-add -lshows the time left after each key's comment, e.g.laptop (14m30s left), on a server you've forwarded the agent to as well as locally. - Every signature is approved, in a pinentry popup, at the agent's
terminal, or in
sudo-agent approvein any terminal, whichever you answer first; the others close. The prompt shows which local process is asking and which key it wants, so a request you didn't start (for example from someone else with access to the forwarded socket) is visible rather than silent. In the popup, click Allow; Deny, Escape or closing it denies. At a terminal, approving takesyoryesplus Enter, and keys typed before the prompt appeared are discarded; anything else denies. With no answer anywhere in time, the request is denied. - Two sockets.
agent.sockis the one you forward: it can only list keys and ask for signatures, and it signs onlypam_ssh_agent_authchallenges and SSH logins (sossh,scpandgit pushonwards from the server work), nothing else.control.sockis for adding and removing keys (ssh-add) and is never forwarded; see Security notes. - One request at a time. Concurrent requests queue, and every approval and refusal is logged.
cargo install --locked sudo-agent--locked builds with the dependency versions this release was tested and
checked with the dependency scanners. Or from a checkout:
cargo build --release --locked.
Building needs Rust 1.88 or newer.
Start the agent on the machine that holds your key:
sudo-agent serve
# 2026-10-20 14:03:12 Agent listening on /run/user/1000/sudo-agent/agent.sock
# 2026-10-20 14:03:12 Control socket on /run/user/1000/sudo-agent/control.sock (for ssh-add; never forward it)
# 2026-10-20 14:03:12 Approvals: a pinentry popup, sudo-agent approve or this terminal, whichever answers first
# 2026-10-20 14:03:12 No keys loaded yet; add one with: SSH_AUTH_SOCK='/run/user/1000/sudo-agent/control.sock' ssh-add <key>
# 2026-10-20 14:03:12 Forward it with: ssh -o ForwardAgent='/run/user/1000/sudo-agent/agent.sock' <host>Everything goes to stderr, each line starting with the local time (left out when stderr is the systemd journal, which adds its own).
serve can be left out: sudo-agent [OPTIONS] does the same, as in 0.1.
With ssh-add through the control socket, while the agent runs:
SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/sudo-agent/control.sock ssh-add -t 4h ~/.ssh/id_ed25519-tsets the key's TTL, cut down to--max-ttlif it's longer (the agent logs it). Without-t(or with-t 0) the key gets--ttl: a key never stays until removed.- Adding a key that is already loaded replaces it and restarts its TTL; that's how you extend a session.
-llists keys with their time left,-dremoves one,-Dremoves all.-cis accepted (every signature is approved anyway).-h(destination restrictions) and other constraints are refused rather than silently ignored, as are certificates and smartcard keys.
The passphrase is ssh-add's business, so any way of supplying it works;
sudo-agent only receives the decrypted key. For example, an
SSH_ASKPASS helper that reads it from a password manager (here rbw,
a Bitwarden client):
#!/bin/sh
# ~/.local/bin/askpass-rbw: $1 is ssh-add's prompt, which names the key.
case "$1" in
*id_ed25519*) exec rbw get ssh-passphrase-id_ed25519 ;;
*) exit 1 ;;
esacSSH_AUTH_SOCK=$XDG_RUNTIME_DIR/sudo-agent/control.sock \
SSH_ASKPASS=$HOME/.local/bin/askpass-rbw SSH_ASKPASS_REQUIRE=force \
ssh-add -t 4h ~/.ssh/id_ed25519Or load keys at startup, with the passphrase asked in the agent's terminal:
sudo-agent serve --key ~/.ssh/id_ed25519 (repeatable).
| Option | Default | |
|---|---|---|
--key <PATH> |
none | OpenSSH private key to load at startup; repeatable |
--ttl <DURATION> |
15m |
TTL for --key keys and for ssh-add without -t (90s, 15m, 8h) |
--max-ttl <DURATION> |
8h |
Longest TTL a key may have: a longer --ttl is an error, a longer ssh-add -t is cut down |
--approval-timeout <DURATION> |
2m |
How long the agent's terminal prompt and sudo-agent approve wait for an answer |
--pinentry <PROGRAM> |
pinentry |
Program for approval popups; see Approval popups |
--pinentry-timeout <DURATION> |
15s |
How long a popup waits for an answer |
--no-pinentry |
No popups: approve at the agent's terminal or with sudo-agent approve |
|
--agent-socket <PATH> |
$XDG_RUNTIME_DIR/sudo-agent/agent.sock |
The socket to forward. Also SUDO_AGENT_SOCKET; the flag wins. --socket still works |
--control-socket <PATH> |
$XDG_RUNTIME_DIR/sudo-agent/control.sock |
The socket for ssh-add and sudo-agent approve; never forward it. Also SUDO_AGENT_CONTROL_SOCKET; the flag wins |
Each socket path uses its flag first, then its non-empty environment
variable, then the default. Without an absolute $XDG_RUNTIME_DIR, the
sockets go to ${XDG_CACHE_HOME:-~/.cache}/sudo-agent/agent-<hostname>.sock
and control-<hostname>.sock (a relative XDG_CACHE_HOME is ignored).
The default directory is private (0700) and sockets are owner-only
(0600). serve refuses to start while another agent answers on either
chosen socket; separate socket paths allow separate instances.
serve runs in the foreground (or as a service),
and approval prompts appear in its terminal, as popups when there's a
display, and in every running sudo-agent approve. Ctrl-C at a prompt
denies that request; Ctrl-C otherwise (or twice within two seconds, or
closing the terminal) stops the agent, drops the keys and removes the
sockets. A prompt or popup is withdrawn if its client goes away (e.g. the
SSH connection closes) or its key expires or is removed. A service or a
detached agent keeps running when you close the terminal used to start it.
As a systemd user service, started with your desktop session and stopped at
logout. From a repository checkout, copy
contrib/systemd/sudo-agent.service
into place and enable it (the unit isn't included in the crate package):
mkdir -p ~/.config/systemd/user
cp contrib/systemd/sudo-agent.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now sudo-agent
journalctl --user -u sudo-agent # its log- It runs
~/.cargo/bin/sudo-agent serve, wherecargo installputs it. For another path or other options,systemctl --user edit sudo-agentand replace the command (the emptyExecStart=clears the old one):[Service] ExecStart= ExecStart=/usr/local/bin/sudo-agent serve --ttl 30m - The sockets are the usual ones, so add keys with
ssh-addand forwardagent.sockas shown here. The service has no terminal: approvals come as popups and throughsudo-agent approve. Without a display,sudo-agent approveis the only way, and requests are refused while none is running (its first log lines say so). - Stopping or restarting it (
systemctl --user restart sudo-agent), or logging out, drops all keys. - It isn't started when you're only logged in over SSH, with no desktop
session;
systemctl --user start sudo-agentstarts it by hand. - It needs a desktop that starts
graphical-session.targetand passes its display to systemd (GNOME, KDE Plasma and Cinnamon do). Without one, e.g. a bare window manager, it never starts by itself and may get no popups. Run this from the session's startup instead of enabling it, or usesetsid -fbelow:systemctl --user import-environment DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS systemctl --user start sudo-agent
Without systemd, detach it from the terminal and log to a file; stop it
with pkill -x sudo-agent:
setsid -f sudo-agent serve >>~/.cache/sudo-agent.log 2>&1When DISPLAY or WAYLAND_DISPLAY is set, each request also opens a
pinentry popup (it comes
with GnuPG on most desktops), unless --no-pinentry is given. The startup
line Approvals: … says whether popups are used.
- The default
pinentryis the one GnuPG uses: on Fedora, openSUSE and Arch a script that picks a variant, on Debian and Ubuntu whichever variantupdate-alternativeschose.--pinentrynames another, e.g.--pinentry pinentry-qt(or on NixOS, where there is no default). - It needs a graphical variant (
pinentry-gnome3,-qt,-gtk). A terminal one (pinentry-curses,-tty) can't work for the agent: it fails without touching the agent's terminal, the failure is logged once, and the other prompts still work. - A popup that fails or times out doesn't deny the request while the
terminal prompt or a
sudo-agent approveis still waiting. pinentry-gnome3holds the keyboard and mouse while it's open, so the terminal can't be reached until you answer the popup or it times out (--pinentry-timeout); the terminal prompt then still waits.pinentry-qtdoesn't, so with it the terminal can be answered (or Ctrl-C pressed) while the popup is open.- With neither a terminal nor a display, only
sudo-agent approvecan approve requests, and while none is running every one is refused; the startup line says so.
sudo-agent approve shows a running agent's requests in the terminal you
start it in, and takes the same y or yes plus Enter. Use it when the
agent runs as a service, which has no
terminal of its own, or when you're logged in to the machine over SSH and
can't see its desktop:
$ sudo-agent approve
2026-10-21 03:54:09 Waiting for approval requests from /run/user/1000/sudo-agent/control.sock; Ctrl-C quits
Signature request from pid 188732, uid 1000: ssh server
key: laptop (SHA256:…), 3h57m left
signing: a sudo challenge for user alice
Allow? (closes in 2m00s) [y/N] y
2026-10-21 03:56:15 Allowed
- It runs on the machine with the agent, as the same user. It talks to
control.sock, found likeservefinds it (--control-socket, orSUDO_AGENT_CONTROL_SOCKET). - The popup, the agent's own terminal and every running
sudo-agent approveare asked at once. The first answer counts, and the others close: anapprovethen printsWithdrawn: …with the reason, e.g.answered by approve client pid …orno answer within 2m. One that answered too late printsToo late: …. - An
approvecan answer a request for up to--approval-timeout(2m), counted from when the request arrives, as the popup's--pinentry-timeoutis. Under the service, that is your limit once the popup has closed by itself. To give it longer, raise--approval-timeoutwithsystemctl --user edit sudo-agent. - It only counts while it runs: with none running, a request doesn't wait
for one. If every
approvequits while a request is open, it waits 5 seconds for one to come back, then stops waiting; the request is then refused unless the popup or the agent's terminal is still asking. - Ctrl-C at a prompt denies that request (a second one within two seconds quits); with no prompt open, Ctrl-C quits.
- If the agent stops (restarted, or you logged out),
approvesays so and attaches again when it's back. The keys are gone with the old agent, so add them again.
Keep your normal agent for the login and forward only sudo-agent's
agent.sock:
ssh -o ForwardAgent=/run/user/1000/sudo-agent/agent.sock serverOr set it per host in ~/.ssh/config. ForwardAgent doesn't expand
${XDG_RUNTIME_DIR}, so use the literal path:
Host server
ForwardAgent /run/user/1000/sudo-agent/agent.sock
ssh forwards an agent only if it has one of its own (SSH_AUTH_SOCK or
IdentityAgent). Without one, ForwardAgent=<path> silently does nothing.
Then sudo on the server makes the approval prompt appear on your machine:
Signature request from pid 402599, uid 1000: ssh server
key: laptop (SHA256:…), 12m30s left
signing: a sudo challenge for user alice
Allow? [y/N] y
Approved (terminal) signature request for a sudo challenge for user alice with key laptop (SHA256:…) from pid 402599, uid 1000: ssh server
The popup shows the same text, says when it closes by itself
(--pinentry-timeout), and has Allow and Deny buttons.
The prompt names the local ssh process that carries the forwarded
request, and the user the challenge is for. It can't see which command
runs on the server.
Key types: Ed25519 is recommended and the one tested end to end.
ECDSA should work but is untested, and its signing uses crypto-bigint
0.5, which has a timing advisory (RUSTSEC-2026-0387; our advisory:
GHSA-rj9x-w6xp-x994). RSA works only when
the requester asks for rsa-sha2-512 signatures, which older
pam_ssh_agent_auth versions may not do. RSA signing also uses the
rsa crate, which has an unfixed timing side-channel advisory
(RUSTSEC-2023-0071; our advisory: GHSA-wwj9-h88v-6483). Every
signature waits for your approval, which makes the timing hard to measure
from a server (ECDSA likewise), but prefer Ed25519. Security keys
(ed25519-sk, ecdsa-sk) and DSA keys aren't supported: --key and
ssh-add refuse them.
Setup is manual on purpose: a broken PAM config can lock you out of root,
and distributions differ. sudo-agent never edits system configuration.
Keep a root shell open while you change any of this.
- Install
pam_ssh_agent_auth:libpam-ssh-agent-authon Debian/Ubuntu,pam_ssh_agent_authon openSUSE. Tested with 0.10.4 (openSUSE) and 0.10.3 (Debian). - Put your public key in a root-owned file that you can't write to, e.g.
/etc/security/authorized_keys. If the file is user-writable, anyone who gets your account can add a key and become root. - Let
sudokeep the agent socket (visudo):Defaults env_keep += "SSH_AUTH_SOCK" - Put the module in front of the password stack in
/etc/pam.d/sudo, assufficientso a refusal falls back to the password:auth sufficient pam_ssh_agent_auth.so file=/etc/security/authorized_keys
To check it: ssh-add -l on the server should list your key with its
time left, and sudo -k; sudo -v should prompt on your machine instead of
asking for a password.
- The approval prompt is a sanity check and a tripwire, not a vault. Root on the machine running the agent can read keys from its memory.
- Anyone with root on a server you're connected to can send requests
through the forwarded socket, but each one needs your approval, and you
see it happen. They can't add, remove or lock keys through it: the agent
refuses that on
agent.sockand logs the attempt. - Every prompt says what is being signed:
a sudo challenge for user …oran SSH login as user …. Read it: a server you're on could send a login to another host in place of thesudoyou just ran, and the prompt is where that shows. (Asudochallenge can't be replayed as a login.) agent.socksigns onlypam_ssh_agent_authchallenges and SSH logins. Anything else, such as anssh-keygen -Y sign(git) signature, is refused before you're asked, and logged.ssh-keygen -Y signworks againstcontrol.sock, still only once you approve.- Connections are bounded: 64 on
agent.sock, 32 oncontrol.sock, with request frames limited to 256 KiB on both.agent.sockcloses idle connections after 60 seconds;control.sockmay stay idle. Both allow 10 seconds to finish a frame body or write a reply, so a client that stops reading can't hold a connection slot indefinitely. control.sockmust never be forwarded, and the agent makes sure a mistake there (say, a copy-pastedForwardAgentline) doesn't hand a server control over your keys. It accepts only your own user, and refuses connections that come throughssh: OpenSSH 8.9 and later announce those with asession-bind@openssh.commessage, and as a further tripwire the agent refuses any client whose executable is namedssh. Every refusal is logged.- Any process running as you can answer requests through
control.sock, as it could runsudo-agent approveitself; that's the same trust as adding keys withssh-addthere. It doesn't go unseen: the popup and everyapproveshow the request until it's answered, the answer is logged with the answering process (Allowed by approve client pid …), and otherapproves are told who answered. - The terminal prompts need
yplus Enter and ignore type-ahead. A popup can be approved with a click or Enter, so a keystroke meant for another window could approve one that appears while you type. You still see the popup, and you expect one for everysudoyou run. - Peer details in the prompt come from the requesting process and are informational. They are escaped so they can't rewrite the prompt.
Planned, roughly in order:
sudo-agent stop, lock/unlock, andsudo-agent status;- clearer withdrawal reasons when another backend answers or a request is no longer needed;
- showing the forwarding chain (which host a request came through) in the prompt.
MIT