SSH command generation & the askpass mechanism
This is the heart of sshelf and its trickiest part. Read carefully before touching ssh.rs
or askpass.rs.
1. Building the ssh argv
From a Host, build (in order):
ssh
[-i <identity_file>]... # one -i per entry in identity_files (auth = "key")
[-p <port>] # only if port present and != 22
[-J <jump1,jump2,...>] # ProxyJump chain (jump_hosts), comma-joined
| -o ProxyCommand=... # instead of -J, for one hop with a secret to protect (§3a)
-o StrictHostKeyChecking=accept-new # see §3: keeps host-key prompt away from askpass
[-o PreferredAuthentications=publickey[,keyboard-interactive]] # key hosts only (§3)
<extra_args...> # raw, split with `shlex`, appended verbatim
<user>@<hostname> # user defaults to $USER if unset
- Pure flags only, with no temporary
ssh -Fconfig files (keeps the “never touch SSH config” promise literal and avoids cleanup). extra_argsis the escape hatch for anything the wizard doesn’t model (-X,-L ...,-o ...). Split withshlex::splitso quoted args survive.- Example: stored host
mike@10.25.25.25with key~/.ssh/infra-key→ssh -i /home/mike/.ssh/infra-key -o StrictHostKeyChecking=accept-new mike@10.25.25.25in the printed/yanked command (the exec path expands~internally as well).
The same builder backs the Ctrl-y yank action and sshelf print-command <host>
(copy/print the exact command without connecting). For copy/paste safety, identity-file ~
is expanded before shell-quoting; quoted ~ would not expand in the user’s shell.
Both resolve the host’s stored secret first, so what you copy is the command sshelf would
actually run, including the ProxyCommand a jump host gets when a secret is in play (§3a).
sshelf list --json is the exception: its command field is built as if no secret were
stored, because a listing must not read the keyring once per host. That is also the right
answer for a command you run by hand, which has no askpass helper for a hop to inherit.
2. Launch handoff (exec)
On connect:
- Persist frecency first (
exec()never returns, so nothing runs after it). - Set environment for the child:
SSH_ASKPASS = <path to sshelf's own binary>(std::env::current_exe())SSH_ASKPASS_REQUIRE = forceSSHELF_ASKPASS = 1← how the re-exec’d binary knows it’s in askpass modeSSHELF_HOST_ID = <id>← which secret to fetchSSHELF_SECRET_KIND = password | passphrase | agent← which secret that id holds (§3)SSHELF_IDENTITY_FILES = <path>[:<path>...]← key hosts only,~already expanded (§3)SSHELF_CONNECT_ID = <ulid>← fresh for every wired command, so the helper can spot a repeated prompt (§3b). An id, nothing secret.env_remove("SSH_ASKPASS")of any inherited value first, then set ours (avoid pollution).
- Tear down the TUI:
disable_raw_mode()→LeaveAlternateScreen→ show cursor → flush. std::os::unix::process::CommandExt::exec()intossh. If it returns, it errored → restore terminal, show the error.
A RAII guard + panic hook guarantees step 3’s teardown also runs on panic/early-exit.
2a. The tmux handoff
With tmux = "window"/"pane" and $TMUX present, steps 3 and 4 are replaced by a spawn and
sshelf keeps running:
tmux new-window|split-window
-d # open it in the background
[-e SSH_ASKPASS=<self>] [-e SSH_ASKPASS_REQUIRE=force]
[-e SSHELF_ASKPASS=1] [-e SSHELF_HOST_ID=<id>] # only when a secret is stored
[-e SSHELF_SECRET_KIND=...] [-e SSHELF_IDENTITY_FILES=...]
[-n <host name>] # new-window only; split-window has no -n
-- # ends tmux's own options
ssh <the argv from §1, as separate arguments>
- Frecency is still persisted first; the spawn is as much a point of no return as
exec(). -dcreates the window or pane without switching to it, so focus stays on the picker and the next host is one keypress away.SSHELF_CONNECT_IDis never passed, so a tmux window gets no repeated-prompt marker (§3b).- The argv is passed as separate arguments, never one joined string, so tmux
execvps it and a path containing a space survives. -epairs land in the tmux client’s argv, so only non-secret wiring may ride there. A queued 2FA code, a vault master passphrase, or a tmux older than 3.0 (no-e) sends the connection back to theexec()path above, with the reason printed once the TUI is down. Seesecurity.mdand D-025.- So does a host with nothing stored that the first connect would ask for its secret (§2b):
no secret stored yet, connecting here so the first one can be saved. Only the part of the trigger that needs no network is checked in the TUI; the probe runs after teardown.
2b. First connect: ssh-keygen, the probe, and the verify
A connect to a host with nothing stored may ask for its secret first
(Passwords, keys & 2FA). It runs after the
frecency save and before the 2FA prompt and the exec(), and uses up to three kinds of child
process, each with stdin closed, stderr captured, and a deadline:
ssh-keygen -y -P '' -f <identity file> # key hosts, per -i, 5s
ssh -o BatchMode=yes -o ConnectTimeout=15 <argv from §1> exit # the probe: encrypted key only, 30s
ssh -o ConnectTimeout=15 <argv from §1> exit # the verify: helper wired, 30s
ssh-keygenexiting 0 means the key has no passphrase. A non-zero exit whose stderr mentionspassphrasemeans it has one. Anything else (a missing or unreadable file) means sshelf doesn’t ask and lets ssh report the problem. A key whose~-expanded path is longer than 100 bytes is never asked about: OpenSSH prints the path as'%.100s', so the helper’s exact match in §3 could never answer its prompt.- The probe is built unwired, the same as a connect with nothing stored. Any exit status but 255
means the agent or another key already gets in, and the normal connect runs with nothing asked.
255 with
Permission deniedleads to the prompt. On a 2FA key host only a denial that still listspublickeycounts, because a key the server accepted still ends inPermission denied (keyboard-interactive)whenBatchModecan’t send the code. Any other 255 connects normally and lets ssh show the error. - The secret is stored before the verify, and the verify is wired exactly like the real connect,
the §3a jump rules included, so the secret reaches ssh only through the helper. 255 with
Permission deniedis a refusal; any other 255 or the deadline is a failure. Both remove the secret again and exit 1. Any other status is the remoteexit, so the secret worked. 2FA hosts skip the verify. - The
-ooptions come first so they beat anything in the host’sextra_args; ssh keeps the first value it is given. - A host whose jump chain takes the terminal path (§3a) is never asked, since there is no helper to save into.
3. Secret auto-supply and the sharp edges
Applies whenever a stored secret exists for the host: a login password (password
auth) or a key passphrase (key auth with an encrypted key). exec_connect wires the
askpass env only when such a secret exists (wire_askpass); otherwise ssh prompts / uses the
agent normally, and in that no-secret case configure_askpass also strips
SSHELF_VAULT_PASSPHRASE from the child env (ssh has no reason to inherit the vault master
passphrase). In the wired case the variable must stay: the helper runs as ssh’s child and
reads it to decrypt the vault (see docs/security.md).
ssh decides it needs a secret → because SSH_ASKPASS_REQUIRE=force, it executes the helper
as sshelf "<prompt text>" (the prompt is argv[1]; there is no --askpass flag).
The helper:
- Confirms it’s in askpass mode via
SSHELF_ASKPASS=1. - Reads
SSHELF_SECRET_KIND, which says whether the value behindSSHELF_HOST_IDis a loginpassword, a keypassphrase, or nothing at all (agent). A missing or unrecognised kind declines everything. - Inspects
argv[1]by OpenSSH prompt shape, and answers only the shape that matches its own kind:- Ends with
password:(classicuser@host's password:/ PAMPassword:) and the kind ispassword→ fetch the secret forSSHELF_HOST_IDfromsecrets(keyring or age vault), print it, exit0. - Looks exactly like OpenSSH’s local key prompt,
Enter passphrase for key '<path>':, the kind ispassphrase, and<path>is one of the paths inSSHELF_IDENTITY_FILES→ same, print the secret and exit0. - A secret-shaped prompt of the other kind, or a passphrase prompt naming a key this host does not use → exit non-zero to decline. It is never answered with the queued code either.
- Anything else (host-key
yes/no, OTP/verification codes, arbitrary server text) → the one-time code inSSHELF_2FA_CODEwhen one was queued for this connection, otherwise exit non-zero to decline. Never blindly print the secret.
- Ends with
Why shape alone is not enough
SSH_ASKPASS_REQUIRE=force makes ssh route every read_passphrase() call to the
helper, including the first-connect “Are you sure you want to continue connecting
(yes/no/fingerprint)?”. If the helper answered that with the stored secret, the connection
breaks.
The prompt text of a keyboard-interactive round is written by the server, and Password:
is a perfectly well-shaped prompt. So a host that rejects your key can ask for a password over
keyboard-interactive and, before 0.14.0, be handed the key’s passphrase. That is why the kind
is passed in and why a key host is also told which key files are in play: the only passphrase
prompt it will answer is OpenSSH’s own, naming a path it was given with -i. Defenses, in
order of how much they carry:
- The helper answers only prompts of its own kind, and a key host only for its own key files.
- Key hosts pass
-o PreferredAuthentications=publickey, so the server cannot offer password auth at all. A key host that also needs a verification code passespublickey,keyboard-interactive, since that is how the code arrives. - The helper matches the shape of real prompts rather than a bare substring, so “Type your password to continue:” is not treated as a secret prompt.
- sshelf passes
-o StrictHostKeyChecking=accept-new, so the host-key prompt never fires for new hosts (known hosts are still verified; changed keys still hard-fail). - The secret is host-scoped, limiting blast radius even if a prompt is mis-answered.
3a. The jump hop never sees the helper
ssh starts the ProxyJump hop as a child process, so it inherits SSH_ASKPASS and the rest
of the wiring. It does not forward the destination’s -o options to that hop: only -l,
-p, -J, -F and -v cross over. Nothing on the target’s command line constrains the hop,
so a hostile or compromised bastion could ask for a password and be handed the target’s stored
secret. What sshelf does instead, whenever the helper would be wired at all (a stored secret,
or a queued verification code):
-
One jump host, and the string is made only of
A-Za-z0-9._@:[]-: drop-Jand pass-o ProxyCommand=ssh -o BatchMode=yes -o PasswordAuthentication=no \ -o KbdInteractiveAuthentication=no [-l USER] [-p PORT] -W '[%h]:%p' JUMPUSER,PORTandJUMPcome from the storeduser@host:port.BatchMode=yeson its own disables password prompts; the two explicitnos are there so the rule does not rest on one reading of the man page. A hop reached this way can authenticate with an agent or an unencrypted key file and nothing else, which is what the FAQ always said jump hosts had to be. The allowlist is narrow becausesshruns aProxyCommandthrough your shell. -
Two or more hops, or one that does not parse or does not pass the allowlist:
-Jstays exactly as stored and nothing is wired.sshasks for the target’s secret on the terminal, and sshelf printsmulti-hop jump with a stored secret: ssh will ask for it on the terminalfirst so that is not a surprise. In tmux mode the connection falls back to the in-place handoff for the same reason: a new window has no terminal to ask on. -
Nothing stored and no code queued (agent hosts, key hosts with an unencrypted key):
-Jis untouched. There is no helper for a hop to inherit.
master_args (the transfer ControlMaster) and build_forward_command (port forwards) build
their argv through the same function, so they get the same treatment.
3b. A refused stored secret
ssh asks again after a refused password or passphrase, and a stateless helper would answer every
retry with the same wrong value. Every wired command therefore carries SSHELF_CONNECT_ID, a
fresh ULID, so the helper can tell a second prompt in one connect from the first prompt of the
next. Before answering a secret prompt, the helper creates askpass-<connect id> exclusively, at
mode 0600, in sshelf’s private runtime directory ($XDG_RUNTIME_DIR/sshelf, or
~/.local/share/sshelf/run, the parent of the transfer screen’s mux-<ulid> directories), and
writes the prompt it is answering into it.
- Created: the first secret prompt of this connect. Answer as usual.
- Already there, holding the same prompt: the earlier answer was refused, since that is the only
reason ssh asks again. Print
sshelf: the stored <password|passphrase> for <host id> was refused; replace it with sshelf set-password or ^e in the TUIon stderr and decline. A second line in the marker records that the line was printed, so a password prompt that comes back once per remaining attempt prints it once. - Already there, holding a different prompt (a second key file’s passphrase): answer as usual.
- No connect id (a tmux window, which never gets one), a malformed id, or no runtime directory: answer as usual. The marker fails open; the matching in §3 never does.
The helper never deletes the stored secret. configure_askpass removes askpass-* files older
than ten minutes before it wires a new command. The helper’s stderr is ssh’s stderr, so the line
shows up on the terminal of a real connect, and in the stderr the transfer screen and port
forwards capture, where ssh::classify_auth_error turns it into
could not authenticate: the stored password was refused; replace it with ^e.
Validated by the M0 spike (2026-06-05, macOS, OpenSSH 10.2)
Ran against a real password-auth sshd (lscr.io/linuxserver/openssh-server):
- Success path:
SSH_ASKPASS=helper SSH_ASKPASS_REQUIRE=force,PreferredAuthentications=password,StrictHostKeyChecking=accept-new→ logged in (exit 0). ConfirmsSSH_ASKPASSsatisfies interactivePasswordAuthenticationas well as key passphrases. The helper was called withargv[1] = "tester@127.0.0.1's password: ". - Host-key routing: with
StrictHostKeyChecking=askand a freshknown_hosts, ssh sent the helper the"...continue connecting (yes/no/[fingerprint])?"prompt; a naive helper that always returns the password caused an infinite loop on"Please type 'yes', 'no'...". That is the empirical proof that §3’s two rules are mandatory.
Linux verification is deferred to CI (M8); the mechanism is OpenSSH behavior and is expected to be identical.
4. Known v1 limitations
- Password-auth jump hosts are unsupported, and since 0.14.0 that is enforced rather than
only documented (§3a). The helper only has the target’s secret and can’t tell which hop is
prompting, so a hop is either constrained by an explicit
ProxyCommandor gets no helper at all. Jump hosts must use key/agent auth. - macOS unsigned builds: the re-exec’d askpass child reading Keychain may trigger an OS approval prompt every connect (Keychain ACLs are keyed to code signature). Ad-hoc sign for dev; document for users building from source.
- A key path longer than 100 bytes: OpenSSH truncates it in the passphrase prompt
(
Enter passphrase for key '%.100s':, checked against OpenSSH 10.3), the helper’s exact match declines, and a stored passphrase for that key is never supplied. - Windows: out of scope for v1 (
exec()replacement is Unix-only).
References
- OpenSSH
ssh(1),ssh_config(5)(ProxyJump,StrictHostKeyChecking). SSH_ASKPASS_REQUIRE, added in OpenSSH 8.4 (2020). This machine runs 10.2.std::os::unix::process::CommandExt::exec.