2FA with ssh on OpenBSD

Posted on 2018-08-31

Five years ago I wrote about using a yubikey on OpenBSD. The only problem with doing this is that there's no validation server available on OpenBSD, so you need to use a different OTP slot for each machine. (You don't want to risk a replay attack if someone succeeds in capturing an OTP on one machine, right?) Yubikey has two OTP slots per device, so you would need a yubikey for every two machines with which you'd like to use it. You could use a bastion—and use only one yubikey—but I don't like the SPOF aspect of a bastion. YMMV.

After I played with TOTP, I wanted to use them as a 2FA for ssh. At the time of writing, we can't do that using only the tools in base. This article focuses on OpenBSD; if you use another operating system, here are two handy links.

Seed configuration

The first thing we need to do is to install the software which will be used to verify the OTPs we submit.

# pkg_add login_oath

We need to create a secret - aka, the seed - that will be used to calculate the Time-based One-Time Passwords. We should make sure no one can read or change it.

$ openssl rand -hex 20 > ~/.totp-key
$ chmod 400 ~/.totp-key

Now we have a hexadecimal key, but apps usually want a base32 secret. I initially wrote a small script to do the conversion.

While writing this article, I took the opportunity to improve it. When I initially wrote this utility for my use, python-qrcode hadn't yet been imported to the OpenBSD ports/packages system. It's easy to install now, so let's use it.

Here's the improved version. It will ask for the hex key and output the secret as a base32-encoded string, both with and without spacing so you can copy-paste it into your password manager or easily retype it. It will then ask for the information needed to generate a QR code. Adding our new OTP secret to any mobile app using the QR code will be super easy!

#!/usr/bin/env python

#           DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
#                   Version 2, December 2004

# Copyright (C) 2018 Daniel Jakots


import binascii
import base64
import sys

try:
    import qrcode
except ModuleNotFoundError:
    print("pkg_add py3-qrcode")
    sys.exit(1)

seed_hex = input("Key in hex format ")

binary_string = binascii.unhexlify(seed_hex)
seed_b32 = base64.b32encode(binary_string).decode('utf-8')

print("The secret in a base32 encoded format")
print(seed_b32)

print("The same, but with a space every three letters for readability")
print(' '.join([seed_b32[i:i+3] for i in range(0, len(seed_b32), 3)]))

print("Let's create a QR code to import it into an app")
issuer = input("'Issuer' (can be the server name) ")
username = input("Username ")

uri = f"otpauth://totp/{username}?secret={seed_b32}&issuer={issuer}"
img = qrcode.make(uri)
image_file = open(f"qrcode-otp-{issuer}.jpg", "wb")
img.save(image_file)

You can fetch this script using ftp https://static.chown.me/pub/iota/blog/totp-hex-to-qrcode.py. (The code isn't in any of my public repositories for reasons).

We can check to make sure everything went smoothly by comparing the code provided by your mobile app to one generated by oathtool at the same time. The oathtool binary is provided by the package oath-toolkit (which is the dependency needed by login_oath). oathtool accepts the seed in either hexadecimal or base32 format.

$ oathtool --totp 0123456789abcdef0123
054640
$ oathtool --totp -b AERUKZ4JVPG66AJD
054640

0123456789abcdef0123 is the seed in hexadecimal format (as in ~/.totp-key) and AERUKZ4JVPG66AJD is the same data, but base32-encoded.

Alternatively, if you just want to do the hex -> b32 conversion, login_oath's README gives a Perl example (but it is not an unreadable one-liner, so you may not want to use it):

Some tokens (e.g. Google Authenticator) require secrets in base32 format;
you can convert them with p5-Convert-Base32:

use Convert::Base32;
my $s = pack('H*', '99d12448129d1e8192e063d64714209137a13864');
print encode_base32($s)."\n";

System configuration

We can now move to the configuration of the system to put our new TOTP to use. As you might guess, it's going to be quite close to what we did with the yubikey.

We need to tweak login.conf. Be careful and keep a root shell open at all times. The few times I broke my OpenBSD were because I messed with login.conf without showing enough care.

After the lines:

# Default allowed authentication styles for authentication type ftp
auth-ftp-defaults:auth-ftp=passwd:

we add:

# Default allowed authentication styles for authentication type ssh
auth-ssh-defaults:auth-ssh=-totp:

and inside the class of the user account for which TOTP is being set, we add the line :tc=auth-ssh-defaults:\. For instance, in my case it's:

staff:\
        :datasize-cur=1536M:\
        :datasize-max=infinity:\
        :maxproc-max=512:\
        :maxproc-cur=256:\
        :ignorenologin:\
        :requirehome@:\
        :tc=auth-ssh-defaults:\
        :tc=default:

(Hint: it's the penultimate line). You can check the class of your user using id -c.

sshd configuration

Again, keeping a root shell around decreases the risk of losing access to the system and being locked outside.

A good standard is to use PasswordAuthentication no and to use public key only. Except... have a guess what the P stands for in TOTP. Yes, congrats, you guessed it!

We need to switch to PasswordAuthentication yes. However, if we made this change alone, sshd would then accept a public key OR a password (which are TOTP because of our login.conf). 2FA uses both at the same time.

To inform sshd we intend to use both, we need to set AuthenticationMethods publickey,password. This way, the user trying to login will first need to perform the traditional publickey authentication. Once that's done, ssh will prompt for a password and the user will need to submit a valid TOTP for the system.

We could do this the other way around, but I think bots could try passwords, wasting resources. Evaluated in this order, failing to provide a public key leads to sshd immediately declining your attempt.

Here's the diff of the output when testing with ssh -v using both public-key-only authentication and two-factor authentication:

-debug1: Authentication succeeded (publickey).
+Authenticated with partial success.
+debug1: Authentications that can continue: password
+debug1: Next authentication method: password
+danj@198.51.100.12's password:
+debug1: Authentication succeeded (password).

Improving security without impacting UX

My phone has a long enough password that most of the time, I fail to type it correctly on the first try. Of course, if I had to unlock my phone, launch my TOTP app and use my keyboard to enter what I see on my phone's screen, I would quickly disable 2FA.

To find a balance, I have whitelisted certain IP addresses and users. If I connect from a particular IP address or as a specific user, I don't want to go through 2FA. For some users, I might not even enable 2FA.

To whitelist, we can use the Match keyword. Here are two basic examples:

Match User git
    AuthenticationMethods publickey
Match Address 203.0.113.47 # VPN
    AuthenticationMethods publickey


To sum up, we covered how to create a seed, how to perform a hexadecimal to base32 conversion and how to create a QR code for mobile applications. We configured the login system with login.conf so that ssh authentication uses the TOTP login system, and we told sshd to ask for both the public key and the Time-based One-Time Password. Now you should be all set to use two-factor ssh authentication on OpenBSD!


Thanks Pamela for the proof-reading!