Secrets and host keys¶
The agent runs ssh with no tty, so every prompt ssh raises goes to our askpass program, which
holds nothing and relays the prompt to the agent. The agent answers from the keychain, relays it to
the terminal during add, or refuses. Secrets are collected once, at add, by a connection the
agent itself makes; host keys stay entirely in the user's known_hosts.
The askpass environment¶
Every ssh the agent spawns that may prompt carries:
SSH_ASKPASS=<bundle>/Contents/MacOS/sshdrive-askpass (path taken from the running bundle)
SSH_ASKPASS_REQUIRE=force
SSHDRIVE_ASKPASS_TOKEN=<one-time token minted by the agent for this ssh process>
ssh invokes the program with the prompt text as its argument and reads the answer from its
stdout. It sets SSH_ASKPASS_PROMPT to confirm for yes/no questions and none for
notifications, and leaves it unset for secrets.
The token protocol¶
- The agent mints a token for every master it spawns and for every collect connection, and records
which location and which purpose (runtime or collect) it belongs to. Mux clients run with
BatchMode=yesand get none. - The token goes into that
ssh's environment. AProxyJumphop is spawned by the master, through theProxyCommandthe agent built (SSH process management), and inherits the master's environment, token included. The agent never sees a hop start or exit. - When
sshprompts, askpass opens an XPC connection to the agent and sends:- the token,
- the prompt text,
SSH_ASKPASS_PROMPT,- the argv of its parent
sshprocess, read withsysctl KERN_PROCARGS2.
- The agent refuses to answer a request with no token, a retired token, or a caller that is not a
descendant of the
sshthe token was issued to. - The agent tells which host is asking by the parent argv, not the prompt text: a hop shares the
master's token and is told apart from it only this way (SQ-063). It resolves that argv with
ssh -G. - The agent classifies the prompt (below) and replies. askpass prints the reply and exits.
- The agent retires the token when the master exits. That ends every hop too, since a hop's
-Wpipe closes with the master.
Why this is safe¶
Environment variables are not secret, but a token that exists in one short-lived process tree and
is useless once it exits is enough. Reading it requires reading our child's environment during the
connection, which needs the user's privileges over our processes, and with those an attacker could
run ssh with the user's keys directly.
An askpass that read the keychain itself and trusted a location id from its environment would be a password oracle for any local process. This one holds nothing.
Prompt classification¶
Prompt from ssh |
Keychain item | Answer |
|---|---|---|
Enter passphrase for key '<path>': |
passphrase:<path> |
the stored passphrase |
<user>@<hostname>'s password: |
password:<user>@<hostname>:<port>, the port from the ssh -G resolution of the asking ssh |
the stored password |
a keyboard-interactive password prompt, which ssh presents as (<user>@<host>) Password: with <host> replaced by HostKeyAlias when the config sets one |
password:<user>@<hostname>:<port> for the destination of the asking ssh, identified by its argv and resolved with ssh -G; nothing is parsed out of the prompt text |
the stored password |
the host-key question, recognised by its text: it begins The authenticity of host '<host>' can't be established (or Warning: the <type> host key for … differs from the key for the IP address …) and ends Are you sure you want to continue connecting (yes/no/[fingerprint])? |
none | during add: relayed to the terminal; otherwise refused |
SSH_ASKPASS_PROMPT=confirm, which ssh sets only for its own permission questions |
none | during add: relayed to the terminal; otherwise refused |
SSH_ASKPASS_PROMPT=none (Confirm user presence for key …) |
none | acknowledged; during add this marks the key as touch-required (below) |
Enter PIN for … key, a one-time code, anything else |
none | refused |
The exact prompt strings, trailing spaces included, are SQ-060.
The host-key question has no hint
It arrives with SSH_ASKPASS_PROMPT unset, indistinguishable by hint from a password
prompt (SQ-047). A classifier that trusted the hint would answer a stored password to "Are you
sure you want to continue connecting". So the question's own text is matched first and the
hint is only corroboration. confirm exists and is refused outside add; nothing we have seen
produces it.
The passphrase prompt names the key through %.100s, so a path over 100 bytes arrives truncated
(SQ-048). The agent maps the prefix back onto the asking ssh's own identityfile list from the
same ssh -G resolution and keys on the full path. With no unique match it takes the prompt at its
word.
Keychain keys¶
Items are keyed password:<user>@<hostname>:<port> and passphrase:<keypath>, never by location.
<hostname>is the resolvedhostnamefromssh -G, lowercased assshprints it in the prompt. The alias never appears in a key, sonasandnas.tail1234.ts.netshare one item.- Keying by host rather than location is what makes
ProxyJumpwork with password auth on both hops: each hop's prompt names its own host and gets its own item. - The port is in the key because one hostname routinely fronts several machines on different ports
(the usual NAT layout);
known_hostskeys entries as[host]:portfor the same reason.sshputs no port in any prompt, so the agent adds it from the resolution of the askingssh, found by its argv (a hop carries its-pthere, SSH process management). - An item is shared by every location that names it (the location model).
Prompts with no stored answer¶
A refused PIN, one-time code or confirm makes ssh fail. The domain shows .notAuthenticated,
reconnection stops (SSH process management), and sshdrive status prints the prompt text
so the user knows what the server wanted.
A passphrase prompt for a key with no stored item is answered with an empty passphrase, and nothing is stopped:
sshoffers everyidentityfilein order without decrypting any, because the OpenSSH key format keeps the public half in the clear (an encrypted key with no.pubbeside it is still offered with no passphrase asked). It decrypts a key only once the server accepts it.- So the prompt arises for an unstored key only when the server accepts it as well as the stored one (a personal and a work key on the same account can), or for a key in the old PEM format, which must be decrypted before it can be offered.
NumberOfPasswordPrompts=1bounds passphrase attempts too, sosshgives up on that key after one try and moves to the next. If no key works, the "Permission denied" that follows is classified on exit like any other.
A password prompt with no stored item is answered the same way. ssh gets one password attempt, so
the exit that follows stops reconnection with the prompt text in status: the right outcome for a
server that has started asking for a credential the location does not have.
Collecting secrets at add¶
Collection happens in sshdrive add, and again when sshdrive set changes the host, user, port or
identity. The CLI does not run ssh.
- The CLI asks the agent to make the verification connection (the collect connection).
- The agent runs the exact command it will use later, in its own environment (SSH process management), with the token marked collect.
- For every prompt with no stored answer, the agent calls back to the CLI over the same XPC connection. The CLI shows the prompt, reads the answer (hidden for secrets, visible for the host-key question) and returns it.
- The agent hands the answer to
sshand keeps it in memory. The CLI never holds a secret beyond the prompt. - When the connection succeeds, every answer that was actually used is written to the keychain. A wrong password is never stored.
Because the test connection is the agent's, a location that passes add works from the agent:
there is no second environment for it to fail in. list and show report which items exist
("password stored for alec@nas", "passphrase stored for ~/.ssh/id_nas").
What gets stored:
- Passphrases always, even when
ssh-agentalso holds the key, so the mount works at login before any key agent is unlocked. - Nothing for an unencrypted key or a key that lives only in a key agent.
Two passes: key files first, then the key agent¶
A key agent that already holds the key would defeat collection: ssh signs through the agent,
never opens the key file, never asks for the passphrase, and add stores nothing. The first reboot
then finds an empty agent, falls back to the file, and fails on the refused prompt. So the collect
connection runs at most twice.
| Pass | Runs with | Can use |
|---|---|---|
| 1 | -o IdentityAgent=none |
key files, passphrases Apple's UseKeychain finds in the login keychain, and passwords; every passphrase needed is seen and stored |
| 2, only if pass 1 fails to authenticate | the key-agent socket | agent-only keys too (1Password, Secretive, a FIDO key loaded into ssh-agent) |
- When pass 1 falls through to a password prompt, the CLI says so: "your key files did not authenticate and the server accepts passwords; press Enter to skip this and try your key agent instead". Without it, a user whose only key lives in 1Password and whose server also accepts passwords (SQ-064) would type a password and end up with a location that quietly authenticates by password.
- An empty answer refuses that prompt: nothing is stored and the pass fails over. The same
Enter-to-skip works for a passphrase prompt for a key the user does not mean to use here;
sshmoves to the next identity, exactly as at runtime (above). - Whichever pass succeeded is how the location connects from then on. A pass-1 location runs with
IdentityAgent=nonefor good, so no key agent is ever consulted for it and none can prompt. - A location that passes only pass 2 is recorded as
agentDependent.showsays "authenticates through the key agent only; the mount waits for it after login". While the key agent is unavailable its reconnects use the transient retry of the SSH page: a key-agent socket that is missing or refuses, probed before the spawn at the pathssh -Gresolves foridentityagent, is a transient failure, not an authentication one.
The collect connection of set host|user|port|identity repeats the same two passes.
A stale stored answer¶
A second location on a host whose password has since changed finds the shared
password:<user>@<hostname>:<port> item; ssh uses it for its single prompt
(NumberOfPasswordPrompts=1) and is refused. add then repeats the collect connection with the
stored items for that host masked, so every prompt reaches the terminal, and on success replaces
the item for every location that names it.
Replacing a stored secret on purpose is sshdrive remove then sshdrive add: remove deletes
each item no other location names, and the new add collects it again.
The terminal's environment versus the agent's¶
A tmux session, a forwarded agent socket or a directory-scoped environment can give the terminal a
different SSH_AUTH_SOCK or PATH from the agent's login-shell snapshot, and a key reachable only
through those passes ssh nas there and fails from the agent. So add compares the CLI's two
values with the snapshot before connecting and, when they differ, prints both and says which the
agent will use. "Works in a terminal" means "works in a fresh login shell", and this is where the
user finds that out.
Prompts that need a human every time¶
If the collect connection sees any of these, the location is not created:
- a user-presence notice (a FIDO key that requires a touch),
- a PIN prompt,
- a keyboard-interactive prompt that is not a password (a one-time code).
Such a location would mount once and then fail into .notAuthenticated on the first unattended
reconnect, with nobody there for the touch. add explains which prompt it saw and what works
unattended: a key held by a key agent (1Password, Secretive and ssh-agent show their own prompt
or none), a FIDO key generated with no-touch-required, or a password. sshdrive unlock for
one-time codes is future work.
Naming the touch key¶
ssh offers identities in the order ssh -G lists them, and the default list includes
~/.ssh/id_ecdsa_sk and ~/.ssh/id_ed25519_sk. So a touch-required FIDO key that sits in ~/.ssh
and that the server also accepts is used, and asks for its touch, before a passphrase key or
password that would have worked unattended gets its turn. (A key the server does not accept is
offered and passed over without a prompt.)
The user-presence notice carries the key type and fingerprint
(Confirm user presence for key ED25519-SK SHA256:…). The agent matches the fingerprint against
ssh-keygen -lf of every identityfile in the ssh -G output, and add names the file and the
way around it:
~/.ssh/id_ed25519_skneeds a touch on every connection; runsshdrive add --identity ~/.ssh/id_nas nasto authenticate with a different key
--identity stores the override with IdentitiesOnly=yes (the location model), so
the touch key is never offered again for that location. Nothing is stored unless the user asks: a
location added without --identity keeps following ~/.ssh/config, and a key added to the config
later is picked up on the next connection.
The authentication deadline¶
A key held by a key agent is signed by the agent, and any touch or biometric prompt comes from the
agent's own UI, never through askpass. Secretive keys that require Touch ID, 1Password's
per-session authorisation and a FIDO key loaded into ssh-agent all pass the collect connection
while the user is at the keyboard, then wait for a human on every unattended reconnect. add cannot
detect these, and ConnectTimeout does not cover authentication.
So every connection has a deadline of the agent's own:
- If the master's control socket has not appeared 60 s after
sshwas spawned, the agent kills it.sshcreates the socket only once authentication has succeeded (SSH process management). - The 60 s run from the spawn and contain the 15 s
ConnectTimeoutof the TCP and banner phase (offline behaviour), because the agent has no signal for when that phase ended. AProxyCommandthat takes ten seconds to hand over a connection leaves fifty for authentication.
The collect connection runs to 300 s
Its prompts go to a terminal, and a user reading a fingerprint and typing a password for each
hop of a chain routinely takes longer than 60 s. The 60 s exists because nothing may wait for a
human on an unattended connection; somebody at the keyboard is the premise of this one. The
master add brings up afterwards carries the ordinary 60 s: that is the connection that
has to work unattended.
What a deadline expiry means depends on the location:
| Location | Treated as | Result |
|---|---|---|
agentDependent |
an authentication failure, for the reconnect loop | Reconnection stops. status says "authentication did not complete within 60 s; a key agent may be waiting for a touch or approval", with the fix: use a stored passphrase, a key-agent key that does not prompt, or a password, then sshdrive debug breaker <name> --connect |
| any other | a slow or wedged server: it runs with IdentityAgent=none, so nothing on the Mac can be waiting for a human |
Retried with the network backoff like any other connection failure (offline behaviour) |
Re-arming after a deadline stop¶
The commonest cause of a deadline stop is a 1Password or Secretive agent that only gives approval while somebody is at the keyboard. After every sleep the master is dropped (SSH process management), the reconnect blocks on a prompt nobody is there to answer, and the deadline fires. Leaving that stopped until the user clears it by hand would make the mount die every morning for exactly the key agents goals and non-goals supports. So a deadline-stopped location is re-armed for one attempt when a human is demonstrably present.
A File Provider request on its own is not evidence of that. Spotlight, Quick Look, Finder's background refreshes and the working-set enumerator issue requests on an unattended Mac all day; each would re-arm an attempt, block for 60 s, raise the key agent's prompt with nobody there, time out and hand the trigger to the next request. Presence is measured directly:
| Test | Source | Passes when |
|---|---|---|
| input idle time | CGEventSource.secondsSinceLastEventType over the combined session state (keyboard, mouse, trackpad; needs no permission) |
under 30 s |
| screen lock | CGSSessionScreenIsLocked in CGSessionCopyCurrentDictionary() |
unlocked |
Two things re-arm the attempt:
- the
com.apple.screenIsUnlockeddistributed notification; - a File Provider request for that domain arriving while the presence test passes. The test is evaluated at most once a minute, so it costs nothing.
The rules around it:
- The domain is not disconnected for a deadline stop (offline behaviour), so requests keep arriving and failing fast (MQ-038), which is what the request trigger rides on.
- An
agentDependentlocation makes no attempt at all while the screen is locked, wake included. Its first attempt after a sleep is the one the unlock re-arms, so it never spends 60 s prompting a key agent at a locked screen. - The re-armed attempt has the same 60 s deadline. If it times out again the location is stopped until the next trigger, so an unattended Mac never retries and the prompt appears only when the user is there to see it.
- Refused prompts (a PIN, a one-time code, a
confirmoutsideadd) are never re-armed: they cannot succeed attended or unattended.
Measured against a forced deadline stop (2026-09-04): the unlock trigger re-arms exactly one attempt, a request with input idle at 45 s re-arms nothing, a request with input idle at 2 s re-arms one, and a burst of requests inside one minute costs a single presence reading.
launchctl setenv does not reach a launchd agent on macOS 26 (MQ-067), so anything that overrides
the presence reading for a test does it through a file in the group container, never the
environment.
Host keys¶
ssh checks the server against ~/.ssh/known_hosts as it always does. We keep no host-key state
of our own, so ssh, sftp and SSH Drive can never disagree about a server.
| Connection | Host-key options | An unknown or changed key |
|---|---|---|
add (collect) |
ssh's default StrictHostKeyChecking=ask; --trust-first passes accept-new instead |
Unknown: ssh's fingerprint question arrives at askpass with SSH_ASKPASS_PROMPT unset, is recognised by its text, and is relayed to the terminal; ssh writes the answer to known_hosts as it would from a tty. Under --trust-first no question is asked |
| every other | StrictHostKeyChecking=yes, UpdateHostKeys=no; any confirm prompt is refused |
Changed: ssh exits with its "REMOTE HOST IDENTIFICATION HAS CHANGED" banner, which the agent recognises on stderr. The domain goes .notAuthenticated, reconnection stops (SSH process management), and status shows the fingerprint ssh reported and the fix |
The fix for a changed key is ssh-keygen -R <host>, accepting the new key with ssh once, then
sshdrive debug breaker <name> --connect.
UpdateHostKeys=no matters because UpdateHostKeys ask in the user's config passes straight
through ssh -G and would raise a confirm on a healthy server, which the refusal rule would turn
into a stopped location.