Security & threat model
sshelf stores SSH passwords so it can auto-supply them. This document states exactly what
that protects against and what it does not. The shipped root SECURITY.md (M8) is a
user-facing summary of this.
Strongly prefer SSH keys / agent over stored passwords. Password storage exists for hosts you can’t use keys with; it is the least secure option
sshelfoffers.
Where secrets live
- The OS keyring, the primary store: macOS Keychain (
security-framework) or Linux Secret Service over D-Bus (keyringcrate). Servicesshelf, account = hostid. - The
agevault (opt-in, headless): ifSSHELF_VAULT_PASSPHRASEis set, secrets go in the XDG data dir asvault.age, encrypted with that passphrase (agepassphrase mode = scrypt KDF + ChaCha20-Poly1305). This is the path for headless Linux with no Secret Service daemon, and for automation/CI. v1 reads the passphrase from the env var (deterministic, scriptable); an interactive prompt + auto-detection of a missing keyring are future enhancements. Env-inheritance tradeoff: the askpass helper runs as ssh’s child and reads the env var to decrypt the vault, so for hosts with a stored secret the passphrase is necessarily in the ssh process tree’s environment (/proc/<pid>/environ, same-user readable). For hosts with no stored secret,ssh.rs::configure_askpassstrips the variable before the exec. - Provisioning:
sshelf set-password <name|id>stores a secret from stdin (so it can be piped in headless setups) without going through the TUI. - Never in
hosts.toml,state.json, logs, shell history, or process arguments.
The host-key id is the lookup key in both stores, so renaming a host keeps its secret.
How the password reaches ssh
Via SSH_ASKPASS: ssh calls the helper, which prints the secret on stdout. The password
is never passed as a CLI argument (no sshpass -p), so it never appears in ps/argv. See
ssh-command.md.
The prompt the helper is handed is server-controlled on a keyboard-interactive round, so
matching its shape is not by itself enough: Password: is a well-shaped prompt that any host
can send. The connect therefore also tells the helper which secret it is holding, and the
helper answers only that one:
- A password host answers a login-password prompt and nothing else.
- A key host answers only OpenSSH’s own
Enter passphrase for key '<path>':, and only when<path>is one of the identity files that connect actually passed with-i. APassword:from the server gets nothing. - A secret-shaped prompt of the wrong kind is declined, and is never answered with a queued verification code instead.
- A key host also connects with
-o PreferredAuthentications=publickey(pluskeyboard-interactivewhen the host needs a verification code), so a server cannot steer it into a password prompt in the first place. - Host-key confirmations never arrive, because connect passes
StrictHostKeyChecking=accept-new.
A jump hop is a child of ssh and inherits the same environment, and OpenSSH does not forward
the destination’s -o options to it. So whenever the helper is wired, a single jump host is
replaced by an explicit ProxyCommand with BatchMode=yes, PasswordAuthentication=no and
KbdInteractiveAuthentication=no, which leaves the hop with an agent or an unencrypted key
and nothing else. A chain of two or more hops cannot be constrained that way, so the helper is
not wired at all and ssh asks for the target’s secret on the terminal. Details and the
rejected alternatives: ssh-command.md
and D-029.
Saving a secret on first connect
A connect to a host with nothing stored can ask for the secret on the terminal
(details). The answer is read with echo off
into a zeroizing buffer and stored in the keyring or vault before it is checked, because the
askpass helper reads the store and there is no other way to get the secret to ssh without argv.
The check is one ssh ... exit wired exactly like a connect. If the server refuses the secret,
or the check can’t finish, the secret is deleted again before sshelf exits. 2FA hosts are stored
without a check, since a check would use up the code. The ssh-keygen -y and the probe that come
first never see the secret at all.
Rejected: letting the helper learn the secret from ssh’s own prompt (that would give the helper write access to the store, in the one place the prompt text comes from the server), and storing without a check (a typo would be supplied on every later connect). See D-035.
A refused stored secret
The helper leaves one marker per connect, askpass-<connect id>, created exclusively at mode
0600 in sshelf’s private runtime directory. It holds the prompt the helper answered and nothing
secret. When ssh asks the same question again in the same connect, the stored secret was refused:
the helper prints one line saying so and declines, instead of handing over the same wrong value on
every retry. The connect id is an opaque ULID in the child’s environment and never crosses tmux’s
argv.
The helper never deletes the secret. A server can ask twice for reasons of its own, for example
offering keyboard-interactive and password where the first fails on the server’s side, and a
helper that deleted on a repeat could destroy a correct secret. The user replaces it on the
strength of the message. Details: ssh-command.md.
The tmux boundary
With tmux = "window"/"pane" (Searching & connecting),
a connection is opened by a new tmux window, which is a child of the tmux server and
inherits nothing from sshelf’s own process. tmux’s only channel is new-window -e KEY=VALUE, and
those pairs are the tmux client’s argv: readable by anyone on the machine with ps. That is
exactly the leak SSH_ASKPASS exists to avoid, so the rule is content-based:
- Only the askpass wiring ever crosses:
SSH_ASKPASS,SSH_ASKPASS_REQUIRE=force,SSHELF_ASKPASS=1,SSHELF_HOST_ID,SSHELF_SECRET_KIND, and (for key hosts)SSHELF_IDENTITY_FILES. The id is an opaque ULID; the helper trades it for the secret in the keyring, exactly as it does after anexec(). The kind is one of three words and the identity files are paths you can already read inhosts.toml. No value there is a secret. SSHELF_2FA_CODEandSSHELF_VAULT_PASSPHRASEnever cross. A connection that needs either (a 2FA host, or a stored-secret host in vault mode) falls back to the in-placeexec()handoff, where the environment is passed byfork/execand never appears in argv. A unit test asserts neither variable can appear in a generated tmux argv.- Key/agent hosts pass no environment at all.
- The ssh argv is handed to tmux as separate arguments after
--, so tmuxexecvps it directly rather than letting a shell re-parse it.
Rationale and rejected alternatives: D-025.
Threat model
Protected against
- On-disk plaintext exposure: secrets are in the OS keyring or encrypted at rest in the vault.
- Process-listing / argv leakage: the password is delivered via stdin/stdout to
ssh, not argv; the tmux path (above) is held to the same rule, falling back rather than bending it. - Shell-history leakage:
sshelfnever echoes the command containing a password. - Casual file snooping: the vault requires the master passphrase (memory-hard KDF).
hosts.tomlsharing: it contains no secrets, so it’s safe to commit/share/back up.- Config-file corruption: atomic writes, so a crash mid-write leaves the prior file intact.
NOT protected against (out of scope)
- A root/admin attacker or malware on the machine, which can read process memory, the keyring,
or keystrokes.
sshelfassumes you trust your own machine. - Keyloggers, which can capture the master passphrase as you type it.
- A compromised OS keyring daemon; sshelf trusts the platform’s secret service.
- Physical theft without full-disk encryption. Use FDE, which is an OS-level control.
- Unencrypted backups / cloud sync of
vault.age. The vault is encrypted, but treat it as sensitive and don’t rely on it as your only protection in an untrusted backup.
Assumption: sshelf targets a developer/operator’s own (trusted) machine, not shared or
hostile hosts.
Operational notes
- No password recovery. Forgetting the vault master passphrase means losing vault secrets. Use a passphrase you can recover (e.g. from another password manager).
- macOS unsigned builds: the re-exec’d askpass child reading Keychain can trigger an OS approval prompt on each connect (Keychain ACLs are keyed to code signature). Ad-hoc sign dev builds; release builds should be signed.
StrictHostKeyChecking=accept-newtrusts a new host’s key on first connect but still hard-fails if a known host’s key changes (MITM protection retained).- Network:
sshelfmakes no network connections of its own and has no telemetry; it only ever launches the OpenSSH tools:sshto connect, andssh/sftpfor the file-transfer screen. On a first connect with nothing stored it can also runssh-keygen -yon the host’s key file and one throwawayssh ... exitagainst the host you are connecting to. Transfers authenticate exactly as connect does (keys/agent, or the stored secret viaSSH_ASKPASS) by opening one multiplexedsshControlMaster and runningsftpover it, so there is no extra secret handling and the secret still never reaches argv. Remote paths are quoted forsftp’s parser, control characters are stripped from displayed names, andStrictHostKeyChecking=accept-newapplies there too. The optional transfer log (--transfer-log/$SSHELF_TRANSFER_LOG) records thessh/sftpcommands and their stderr for troubleshooting, and it contains no secrets (same reason: the password goes via askpass).
Reporting
(M8) Add a SECURITY.md at the repo root with a disclosure contact before public release.