DEV Community

Cover image for ssh-keygen -N Puts Your Passphrase in ps
Den
Den

Posted on

ssh-keygen -N Puts Your Passphrase in ps

I'm writing a small GUI that walks a person through creating an SSH key. The core of it is one call to ssh-keygen, and on paper that call is trivial:

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N 'correct horse battery staple'
Enter fullscreen mode Exit fullscreen mode

The user types a passphrase into a nice field, the program puts it after -N, the key appears. Most scripts and wrappers I've looked at do exactly this.

It's also the one way of doing it that I ruled out first.

argv is public

Command-line arguments are not a private channel between you and the program you start. On Linux they live in /proc/<pid>/cmdline, and by default that file is readable by every user on the machine. ps just prints it:

$ ps -eo user,args | grep '[s]sh-keygen'
alice  ssh-keygen -t ed25519 -f /home/alice/.ssh/id_ed25519 -N correct horse battery staple
Enter fullscreen mode Exit fullscreen mode

"But it only runs for a moment." A passphrase-protected key isn't instant to write — ssh-keygen runs a deliberately slow key derivation to encrypt it — and anyway "the window is short" is not a security property. A loop over ps costs nothing to leave running.

And ps is only the most obvious place. The same string can end up in shell history if anyone ever runs the command by hand, in auditd logs that record every execve with its arguments, and in any process-monitoring agent that ships command lines to a dashboard.

A secret in argv has already left the process. The question is only who reads it.

(You can mount /proc with hidepid=2 so users only see their own processes. It's a good setting. It's also not something a desktop app can assume about the machine it runs on.)

Three ways to hand over a passphrase

Once -N is out, you need another channel. I found three approaches in the wild.

1. -N in argv. Covered above. Out.

2. A pseudo-terminal. Start ssh-keygen inside a pty, wait for it to ask, type the answer. This is what expect scripts do, and it works. Two problems for me.

The first is practical: Qt's QProcess doesn't do pseudo-terminals, so I'd be writing forkpty handling by hand.

The second is worse. The program now depends on the exact text of the prompt. And that text changes. On Ubuntu 22.04, which ships OpenSSH 8.9, key generation asks:

Enter passphrase (empty for no passphrase):
Enter fullscreen mode Exit fullscreen mode

Current OpenSSH asks:

Enter new passphrase (empty for no passphrase):
Enter fullscreen mode Exit fullscreen mode

One word. A wrapper that waits for the old string on a newer system just hangs, with no error, waiting for a question that has already been asked differently.

3. SSH_ASKPASS. This is the mechanism OpenSSH itself provides for exactly this case. You point the environment variable at a helper program; when ssh-keygen needs a passphrase, it runs the helper with the prompt as its only argument and reads the answer from the helper's stdout. Seahorse, GNOME's key manager, does it this way — it sets SSH_ASKPASS to its own helper and calls setsid() so the child has no controlling terminal to fall back on.

There used to be a catch: ssh-keygen would only use the helper under certain conditions — a display present, no terminal to ask on. OpenSSH 8.4 added SSH_ASKPASS_REQUIRE=force, which means "always use the helper, no matter what". Ubuntu 22.04 has 8.9, so for my target it's simply available.

You can try it from a terminal with a one-line helper:

#!/bin/sh
# ~/bin/askpass-zenity
exec zenity --password --title="$1"
Enter fullscreen mode Exit fullscreen mode
SSH_ASKPASS=~/bin/askpass-zenity SSH_ASKPASS_REQUIRE=force \
  ssh-keygen -t ed25519 -f ~/.ssh/id_demo
Enter fullscreen mode Exit fullscreen mode

A dialog pops up, twice: once for the passphrase, once to confirm it. Nothing in argv, nothing typed into a terminal you don't control.

The helper has to get the passphrase from somewhere

This is the part that's easy to get wrong after getting everything else right.

If your app already collected the passphrase in its own window and now launches the helper as askpass-helper 'correct horse...', you've moved the secret from ssh-keygen's argv to the helper's argv. Same ps, same leak, one process later.

The cleanest version is the one Seahorse uses: the helper is the dialog. It asks the user directly and prints the answer. The passphrase never passes through the parent process at all — your app doesn't hold it, log it, or forward it.

If the design really requires the main window to collect it, hand it over through something that isn't world-readable:

  • A pipe or a Unix socket in a directory only your user can access, with the path in an environment variable. The helper connects, reads, prints, exits.
  • An environment variable with the passphrase itself is the fallback. It's not great, but it's a different class of exposure: /proc/<pid>/environ is readable only by the process owner and root, unlike cmdline.

Whichever you pick, remember the helper is called twice for a new key — once per prompt — and has to answer both.

It isn't about ssh-keygen

ssh-keygen just happens to be where I met it. The same shape shows up everywhere a tool accepts a secret as an argument: mysql -p<password>, sshpass -p, curl -u user:password. All of them have a quieter alternative — a config file, an environment variable, a prompt, a helper — and all of them still appear in scripts with the secret inline, because the inline version is the one that fits on one line in a README.

If a secret is on the command line, assume it's in a log. Then find the channel the tool's authors built for the case where it shouldn't be — there almost always is one.

Top comments (0)