Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 sshelf offers.

Where secrets live

  • The OS keyring, the primary store: macOS Keychain (security-framework) or Linux Secret Service over D-Bus (keyring crate). Service sshelf, account = host id.
  • The age vault (opt-in, headless): if SSHELF_VAULT_PASSPHRASE is set, secrets go in the XDG data dir as vault.age, encrypted with that passphrase (age passphrase 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_askpass strips 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. A Password: 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 (plus keyboard-interactive when 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 an exec(). The kind is one of three words and the identity files are paths you can already read in hosts.toml. No value there is a secret.
  • SSHELF_2FA_CODE and SSHELF_VAULT_PASSPHRASE never cross. A connection that needs either (a 2FA host, or a stored-secret host in vault mode) falls back to the in-place exec() handoff, where the environment is passed by fork/exec and 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 tmux execvps 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: sshelf never echoes the command containing a password.
  • Casual file snooping: the vault requires the master passphrase (memory-hard KDF).
  • hosts.toml sharing: 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. sshelf assumes 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-new trusts a new host’s key on first connect but still hard-fails if a known host’s key changes (MITM protection retained).
  • Network: sshelf makes no network connections of its own and has no telemetry; it only ever launches the OpenSSH tools: ssh to connect, and ssh/sftp for the file-transfer screen. On a first connect with nothing stored it can also run ssh-keygen -y on the host’s key file and one throwaway ssh ... exit against the host you are connecting to. Transfers authenticate exactly as connect does (keys/agent, or the stored secret via SSH_ASKPASS) by opening one multiplexed ssh ControlMaster and running sftp over it, so there is no extra secret handling and the secret still never reaches argv. Remote paths are quoted for sftp’s parser, control characters are stripped from displayed names, and StrictHostKeyChecking=accept-new applies there too. The optional transfer log (--transfer-log / $SSHELF_TRANSFER_LOG) records the ssh/sftp commands 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.