Chris Dzombak

Securing my personal SSH infrastructure with Yubikeys

One recently-completed project I mentioned in January’s “Now” post was locking down SSH in my personal computing infrastructure using Yubikeys. In this post, I’ll outline my goals, the strategy I took, and the problems and solutions I ran into along the way.

Goals & Strategy

Historically, I’ve used a pretty basic SSH setup for my personal projects: my user account on every laptop/desktop/server had its own key in ~/.ssh, and I’d try to keep the authorized_keys lists on all my servers more-or-less up-to-date. This presents a number of obvious security problems.

I wanted to ensure that, should an attacker gain access to one of my servers, they can’t use that access to move onto any other computer I control. To do this, I moved to using a few Yubikeys to store my SSH keys; there’s no longer key material stored on any server for a hypothetical attacker to steal. The Yubikeys require a PIN, so this is an example of two-factor authentication: something I physically have, and something I know.

SSH agent forwarding is used to allow me to SSH from one server to another or fetch code from GitHub on a remote server. With yubikey-agent, my preferred agent software, every single SSH operation — yes, even those performed via agent forwarding — requires a physical touch to confirm.

I use a private Git repository to synchronize SSH configuration (including authorized_keys, the list of public keys corresponding to my Yubikeys) between machines, with a modular local configuration system allowing me to quickly enable commonly-used SSH configuration blocks which only apply to a subset of my machines.

Implementation

Yubikeys

I found it easiest by far to use yubikey-agent for this project. It’s pretty straightforward to set this up; the real work was figuring out how to smooth out the various difficulties I encountered later.

(The commands and configuration changes under this heading apply to client machines with attached Yubikeys.)

Install yubikey-agent with Homebrew:

brew tap filippo.io/yubikey-agent https://filippo.io/yubikey-agent
brew install yubikey-agent
brew services start yubikey-agent

Run yubikey-agent -setup to generate a new SSH key on your Yubikey.

Set the SSH_AUTH_SOCK environment variable (do this in your .bashrc or .zshrc). In my dotfiles, I first check that yubikey-agent is installed, then proceed:

command -v yubikey-agent >/dev/null 2>&1 && export SSH_AUTH_SOCK="/usr/local/var/run/yubikey-agent.sock"

Enable SSH agent forwarding for a specific, trusted host (don’t enable it for all hosts; that’s a potential security issue) by adding ForwardAgent yes to that host’s block in your ~/.ssh/config. A complete example might look like:

Host dzombak.com
    User chris
    HostName dzombak.com
    ForwardAgent yes

This is a bit of a spoiler for a topic later in this blog post, but we’ll also add this to our ~/.ssh/config:

Host *
    IdentityAgent /usr/local/var/run/yubikey-agent.sock

This allows apps started from outside your terminal — like the GUI Git client, Fork.app — to find and use yubikey-agent.

A note: Secretive.app

I’d like to use the new macOS app Secretive, which stores SSH keys in the Secure Enclave on newer MacBooks and requires Touch ID to authenticate. Unfortunately, for Reasons™ I’m still using macOS Mojave, and Secretive requires Catalina or Big Sur. I plan to move to Big Sur soon enough, since I want to get an M1 MacBook Pro when the 16” models are released, so I’ll be able to try Secretive soon enough. (Worth noting, this changes the security model somewhat, as the second factor is biometric rather than a PIN, but it’s still two factors.)

Server Configuration

Of course, moving to Yubikeys doesn’t solve much if your servers still allow password logins. On every server, in /etc/ssh/sshd_config, I set the following. Make this change only after you’ve set up a Yubikey and added it to authorized_keys for your user account on the server!

ChallengeResponseAuthentication no
PasswordAuthentication no
PermitRootLogin no

(That last line — PermitRootLogin no — ensures that logins as root via SSH are never allowed, which is a good SSH best practice unrelated to Yubikeys.)

Restart the SSH service, and immediately — before logging out — open a new terminal window and test that you can still login to the server with your Yubikey.

macOS tends to lose changes to sshd_config during OS upgrades, so after installing macOS updates I make sure to check that my SSH server configuration is intact.

Git repo for SSH configuration

I keep my ~/.ssh directory, with a few important exceptions (keys are never committed!), in a private Git repo. This allows me to sync configuration and authorized_keys changes between systems easily. I’ve created a stripped-down version at cdzombak/ssh-example which you can use as a basis for your own setup.

I’ll walk through the highlights here:

Those last two — config.local and config.templates — are important, because that’s how I achieve variations in SSH configuration between different machines. The README covers how to enable config templates on a given computer.

Challenges & Solutions

Long-lived screen sessions

I use GNU screen (yes, still; I haven’t bothered to learn tmux) as a terminal multiplexer and to provide persistence between SSH sessions. This was a problem for SSH agent forwarding: when I first SSH in and start a screen session, the SSH_AUTH_SOCK environment variable would be set. But when I logged in from somewhere else and reattached to the screen session, the SSH_AUTH_SOCK environment variable wouldn’t get updated, so SSH agent forwarding was broken until I started a new screen session.

This Gist helped me fix this situation. There are a few parts to this solution. (The commands and configuration changes under this heading apply to servers you’ll SSH into.)

First, we have to have a location for our SSH agent socket that doesn’t change between logins. These few lines in ~/.ssh/rc achieve this:

if test "$SSH_AUTH_SOCK" ; then
    ln -sf "$SSH_AUTH_SOCK" ~/.ssh/sock/ssh_auth_sock
fi

Great! Then we just need clients to use this new, always-updated socket. To do this, we configure ~/.screenrc to set the environment variable SSH_AUTH_SOCK:

setenv SSH_AUTH_SOCK $HOME/.ssh/sock/ssh_auth_sock

Finally, we’ll also want to include IdentityAgent ~/.ssh/sock/ssh_auth_sock in our SSH configuration. We can do this by including the homedir-ssh-auth-sock configuration block within my modular SSH configuration setup:

cd ~/.ssh/config.local
ln -s ../config.templates/homedir-ssh-auth-sock .

SSH agent forwarding when running commands under sudo

When running a command with sudo, you’re working in a new environment; your user’s environment variables are not preserved. This will, of course, break SSH agent forwarding.

To solve this, we want to preserve the SSH_AUTH_SOCK environment variable when using sudo. (The commands and configuration changes under this heading apply to servers you’ll SSH into.)

Run visudo (as root) to edit your sudoers file, and add:

Defaults>root    env_keep+=SSH_AUTH_SOCK

This means that when using sudo to run a command as root (not as any other user), your SSH_AUTH_SOCK variable remains intact, and agent forwarding works as expected.

SSH agent forwarding via SSH_AUTH_SOCK doesn’t work with GUI macOS apps

This issue in the Fork app’s issue tracker was really helpful here. .bashrc and .zshrc don’t apply to GUI apps (unless you launch them from the terminal), so setting SSH_AUTH_SOCK only in shell configuration files won’t work.

This is why we need IdentityAgent /usr/local/var/run/yubikey-agent.sock in our SSH configuration. To enable this within my modular SSH configuration setup:

cd ~/.ssh/config.local
ln -s ../config.templates/yubikey-agent .

(This change applies to macOS client machines with attached Yubikeys.)

Avoiding repeated mystery Yubikey prompts (using HTTPS for GitHub and Bitbucket repositories)

After I set this up, my Yubikey would periodically blink as if I were trying to SSH into something, but I hadn’t tried to do anything with SSH! That was worrying, until I realized it was just Fork trying to update repository info in the background.

I decided to configure Git on my laptops to use HTTPS instead of SSH when communicating with GitHub and Bitbucket, so Fork can work in the background as it desires.

To do this, we’ll add the following to ~/.gitconfig. (This change applies to any Mac where you’d like Git to use HTTPS instead of SSH.)

[url "https://"]
    insteadOf = git://

[url "https://github.com/"]
    insteadOf = git@github.com:

[url "https://bitbucket.org/"]
    insteadOf = git@bitbucket.org:

[credential]
    helper = osxkeychain

Now, when you try to perform a Git operation for a GitHub or Bitbucket repo for the first time, Git will prompt for credentials:

Username for 'https://bitbucket.org':
Password for 'https://bludzombak@bitbucket.org':

For GitHub, your password is a Personal Access Token with repo scope. For Bitbucket, your password is an App Password with Repositories/Write and Repositories/Read permissions.

Git will cache these credentials on macOS’s Keychain for future use.

Servers that need to communicate autonomously

I do have a use case where one server needs to sync data to another periodically using rsync over SSH, meaning a Yubikey that requires physical, real-time interaction is out of the question.

To achieve this, the receiving server has a restricted user account which only allows access to the necessary data. The sending server, who initiates the sync, has an SSH key which is used only for this task; and which can only log into the restricted user account on the receiving server.

For an extra layer of moderate security (it’s still not a real second factor), the receiving server restricts the IP address from which that SSH key can login, using the from directive in its authorized_keys file. See Restricting SSH logins to particular IP addresses and Configuring Authorized Keys for OpenSSH for more details on that.

iPhone SSH clients (Prompt & Secure ShellFish)

I use Prompt 2 on my iPhone occasionally when a laptop isn’t handy. The app generates its own SSH key, which is stored on the iPhone (not in Panic Sync), and I add the corresponding public key to authorized_keys in my SSH configuration repository.

I figure this is fairly secure, because the key stays on my phone and is secured behind Face ID & the iPhone’s PIN, meaning two factors are required: something I have and one of (biometrics or something I know).

It’s a similar story with Secure ShellFish. I use this to remotely access files on my home NAS. In this case, Secure ShellFish’s SSH key actually allows logging in only to a user account on the NAS with limited permissions; it isn’t added to my “core” SSH configuration.

Disaster Recovery

An additional Yubikey lives in a fireproof safe to aid in recovery in case all other Yubikeys are lost or destroyed.

Windows clients (unsolved)

I’m just ignoring this for now. There’s only one Windows machine I use even somewhat regularly, and I never need to SSH into anything from it. It’d be nice to learn about an SSH agent solution that supports Yubikeys or the Google Titan security key, but I have no motivation to work on this myself.