Skip to content

About

SSH askpass UI for macOS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

SSH askpass UI for macOS — FIDO2/SK notifications & agent confirmation

Native macOS dialogs for two things OpenSSH normally only surfaces on a terminal:

  1. FIDO2 / security-key (SK) user-presence — a "touch your key" prompt.
  2. Agent key-use confirmation — the Allow/Deny prompt for keys loaded with ssh-add -c.

It works with the Apple-shipped binaries (/usr/bin/ssh, ssh-agent, ssh-add, OpenSSH_10.2p1) — no rebuild required. Those binaries retain the full SSH_ASKPASS code path; macOS just doesn't ship an askpass program (the compiled default /usr/X11R6/bin/ssh-askpass doesn't exist here). This is that missing program plus the glue to wire it into a launchd agent.

Files

File What it is
ssh-askpass-ui Pure-AppleScript (#!/usr/bin/osascript) askpass program.
de.sektioneins.ssh-agent-ui.plist LaunchAgent template: a private ssh-agent with askpass enabled.
install.sh Installs/uninstalls the askpass and the LaunchAgent for the current user.
README.md This file.

How it works

OpenSSH execs the askpass program with the prompt as argv[1] and selects the behaviour through the SSH_ASKPASS_PROMPT environment variable. The script maps each mode to a native dialog:

SSH_ASKPASS_PROMPT OpenSSH caller Dialog Result contract
none SK touch (notify_start) persistent "SSH Security Key" notice stays up until OpenSSH SIGTERMs it on completion
confirm ssh-add -c, mux prompts (ask_permission) Deny / Allow yes + exit 0 = allow; any non-zero exit = deny
(unset) passphrase / FIDO2 PIN (read_passphrase) hidden-answer field secret printed to stdout, exit 0

Two rules from OpenSSH's readpass.c shape the whole design:

  • Askpass is only used when there is no controlling tty. notify_start writes to the terminal if stderr is a tty; it only spawns askpass when there isn't one. A ssh-agent started by launchd has no tty, so it uses askpass. Plain interactive ssh in Terminal does have a tty — see Scope & limitations.
  • macOS needs an explicit "display" signal. Both notification and passphrase paths require DISPLAY/WAYLAND_DISPLAY to be set or SSH_ASKPASS_REQUIRE=force. There is no DISPLAY on macOS, so the agent's environment must set SSH_ASKPASS_REQUIRE=force alongside SSH_ASKPASS.

Setup

1. Install the askpass and the agent

Run install.sh from the repo checkout:

./install.sh            # link ~/bin/ssh-askpass-ui to this checkout (default)
./install.sh --copy     # or: install a standalone copy

The script:

  1. links (or copies) the askpass program to ~/bin/ssh-askpass-ui;
  2. writes ~/Library/LaunchAgents/de.sektioneins.ssh-agent-ui.plist from the template in this repo and fills in your absolute paths, since launchd does not expand ~;
  3. (re)loads the agent and checks that it answers on ~/.ssh/agent-ui.sock.

Run it again after changing the plist template. Reloading the agent drops the keys loaded into it, so ssh-add them again afterwards.

An existing ~/bin/ssh-askpass-ui is never replaced. If it is already there, it must be a link to this checkout's ssh-askpass-ui or an identical, executable copy of it. Anything else (a link elsewhere, an outdated or modified copy) stops the script before it touches the agent. Remove the file yourself, or run ./install.sh --uninstall, to switch between link and copy or to replace an outdated copy.

Why ~/bin and not ~/.local/bin. Package managers and installers such as uv tool, pipx or the claude installer routinely drop and replace executables in ~/.local/bin. Keeping the askpass out of that directory means no tool update can overwrite or replace the program your agent runs on every confirm/PIN prompt by accident. See Security notes.

Link or copy? Linking is the default, because the link keeps the askpass current with wherever it was installed from:

  • Git checkout: the program is exec'd fresh on every prompt, so a git pull takes effect on the next dialog — no agent reload needed (you only re-run install.sh when the plist changes). Git stores the script as mode 100755, so the executable bit survives clone/pull, and files git writes locally get no com.apple.quarantine, so updates won't reintroduce the Gatekeeper "could not verify" prompt.
  • Package install: the link points to the path install.sh was run from, without resolving symlinks. Run it through the package manager's stable path (e.g. Homebrew's opt/ link, not the versioned Cellar/ directory) and upgrades update the askpass too.

A copy (--copy) is a standalone snapshot that never changes on its own. To update it, remove it and run ./install.sh --copy again.

Security: the askpass runs in your ssh-agent's context on every confirm/PIN prompt, so the link target needs the same trust as ~/bin. install.sh refuses to link to a directory or program that you don't own or that is group/world-writable. Don't link into a shared/multi-user checkout.

Why a private agent. The system agent (com.openssh.ssh-agent) can't be used for this: its plist lives under SIP-protected /System/Library/LaunchAgents, so you can't add environment variables to it. And forcing askpass globally via launchctl setenv SSH_ASKPASS_REQUIRE force would leak into every GUI-session process, pushing your interactive ssh passphrase prompts into popups too. A dedicated agent keeps the askpass behaviour contained.

To check on the agent:

launchctl print gui/$(id -u)/de.sektioneins.ssh-agent-ui | grep -E 'state|SSH_ASKPASS'

2. Point ssh at the private agent

There are two ways, and you'll usually want both — they cover different tools and don't conflict.

A. IdentityAgent in ~/.ssh/config (for ssh/scp/sftp). This is the robust, shell-independent way: it applies even to GUI apps that shell out to ssh, regardless of $SSH_AUTH_SOCK.

Host *
    IdentityAgent ~/.ssh/agent-ui.sock

IdentityAgent overrides $SSH_AUTH_SOCK, supports ~, the %d/%h/%u tokens and $VAR expansion, and accepts none to disable agent use. But it is read only by ssh — ssh-add and ssh-agent ignore it entirely.

B. SSH_AUTH_SOCK in ~/.zshrc (for ssh-add). ssh-add only ever uses this env var, so you need it to load keys into the private agent:

export SSH_AUTH_SOCK="$HOME/.ssh/agent-ui.sock"

Open a new shell (or source ~/.zshrc) and confirm:

echo "$SSH_AUTH_SOCK"        # -> /Users/<user>/.ssh/agent-ui.sock
ssh-add -l                   # talks to the private agent (empty list at first)

If you set only IdentityAgent and skip the export, prefix ssh-add calls explicitly instead: SSH_AUTH_SOCK=~/.ssh/agent-ui.sock ssh-add -c ….

Usage

Agent key-use confirmation (ssh-add -c)

Load a key with -c so the agent prompts on every use:

ssh-add -c ~/.ssh/id_ed25519          # ordinary key, confirm each use

Now each authentication pops a Deny / Allow dialog. Allow approves that one signature; Deny (or Escape / closing the window) refuses it.

FIDO2/SK keys usually don't need -c. By default an SK key requires a physical touch for every signature, and the agent shows the presence dialog (below) each time, so the touch already is the per-use confirmation. Adding -c just puts an extra Allow/Deny click in front of the touch.

The exception is a key created with ssh-keygen -O no-touch-required: the agent signs with it silently, with no dialog and no touch. Always load those with -c:

ssh-add -c ~/.ssh/id_ed25519_sk       # no-touch-required SK key: confirm each use

FIDO2 / security-key touch notification

Load the SK key into the agent so signing happens in the (ttyless) agent:

ssh-add ~/.ssh/id_ed25519_sk
ssh you@host                          # "SSH Security Key — confirm presence" dialog appears; touch the key

If the key also has a PIN, you'll get the hidden-answer PIN dialog first.

Quick standalone test of the dialogs

SSH_ASKPASS_PROMPT=none    ~/bin/ssh-askpass-ui "Touch your security key"   # presence notice (close it)
SSH_ASKPASS_PROMPT=confirm ~/bin/ssh-askpass-ui "Allow use of key X?"; echo "exit=$?"   # Allow->exit 0, Deny->1
SSH_ASKPASS_PROMPT=        ~/bin/ssh-askpass-ui "Enter passphrase"          # secret echoed to stdout

Scope & limitations

  • Interactive ssh with an on-disk SK key still prompts on the terminal, not the GUI. When ssh runs in Terminal it has a tty, and OpenSSH sends the touch prompt there (notify_start only escalates to askpass in batch mode). Fix: load the SK key into the askpass-enabled agent (above) so signing goes through the ttyless agent and the prompt becomes a dialog. (The only other way is a source patch to notify_start — relevant only if you rebuild this tree.)
  • A GUI (Aqua) session is required. osascript display dialog needs the user's window server. It works for a launchd GUI-domain agent and for Terminal, but not over a headless/remote SSH-in session.
  • The presence dialog auto-dismisses after 300s as a safety net so an abandoned prompt can't linger; normally OpenSSH closes it as soon as the touch lands.

Related work

theseal/ssh-askpass is a macOS askpass implementation with a similar goal. Key differences:

Feature theseal/ssh-askpass askpass-ui
Agent strategy launchctl setenv to inject askpass into the system ssh-agent, then stops/restarts it Runs a private launchd agent with SSH_ASKPASS_REQUIRE=force; system agent untouched
Prompt scope Handles ssh host-key prompts too (host key ...) Only the three SSH_ASKPASS_PROMPT modes: none, confirm, passphrase/PIN
FIDO2/SK touch Dialog auto-closes after 15 s timeout Stays up until OpenSSH SIGTERMs it (300 s safety net)
Button defaults "OK" is the default for simple dialogs Deny is default — fails closed for confirmation prompts
Sudo support Also sets SUDO_ASKPASS SSH-only
Install complexity Requires stopping the system agent Zero impact on system agent; parallel setup

Security notes

The askpass program is intentionally pure AppleScript:

  • No shell is ever invoked — no do shell script, no subprocess. There is no command line to inject into.
  • The prompt (argv[1]) and SSH_ASKPASS_PROMPT are consumed as string values passed straight to display dialog; they are never concatenated into executable code, so a hostile prompt (server banner, key comment) can only ever be displayed, never executed.
  • The secret is written only to stdout (the pipe OpenSSH provides) — never into an env var, argv, temp file, or log.
  • confirm fails closed — Deny is the cancel button and there is no default button, so Escape, closing the window, or a stray Enter all deny; only a deliberate click on Allow approves key use.
  • Installed outside ~/.local/bin — the agent runs whatever file SSH_ASKPASS points to, so that path should only change when you change it. ~/.local/bin is shared with installers (uv tool, pipx, claude, …) that write and replace executables there on their own; ~/bin keeps the askpass out of their way. This guards against accidental replacement, not against malware: anything running as your user can still write to ~/bin.

Uninstall

./install.sh --uninstall

This unloads the agent and removes the plist and ~/bin/ssh-askpass-ui. Then remove the SSH_AUTH_SOCK export from ~/.zshrc and the Host * / IdentityAgent block from ~/.ssh/config.

About

SSH askpass UI for macOS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages