This one took some effort. Actually, my first setup had Authy’s OpenVPN plugin and their 7 digit TOTP flavour, but it always felt wrong to require a service in the middle for something which needs a shared secret and the clock to be within the same window. There’s no actual requirement for a service here. Twillio also decided to stop maintaining the Authy plugin, so there’s not a lot of great news in the middleware department.
There’s no TOTP support in OpenVPN. OATH toolkit came to the rescue. To make the two work together, there’s a PAM module for OATH, pam_oath. OpenVPN has a PAM plugin. Now, this seems as easy as RTFM, but, it isn’t. Couple of years ago when I made this setup first, there was no comprehensive end-to-end guide on how to achieve this. PAM isn’t the most friendliest environment to debug. When you add the slowdown of having to input random 6 digit codes for every try to see what’s going on, the whole process comes to a grinding halt.
The OpenVPN authentication strategy:
- Static key tls-auth. This is something which OpenVPN does and it is recommended in most cases. The second benefit of having this on is that the server doesn’t identify itself as OpenVPN, like it does without tls-auth. There’s no banner to grab to please those doing enumeration. If the right TLS key with the right direction isn’t presented within a fairly short window of time, the connection is simply closed.
- Mutual TLS authentication. This is pretty standard in the OpenVPN world where you have a CA, the server gets a cert + key, the clients get certs + keys. This is an excellent guide on how to create a CA. I’m pointing this one out as most guides forget to mention the X509v3 extensions. OpenVPN is honouring the server_cert and the usr_cert extensions which I have accidentally discovered trying to do mutual auth with a cert issued by the same CA with the server_cert extension. Pro Tip: the CRL must not be expired as it drops the mutual authentication despite the server and client having valid certificates. Other people and I have learned this the hard way. This isn’t an OpenVPN specific problem as, for example, I have had the same problem with Haproxy-based mutual authentication and very unhelpful errors about “expired certificates” when the expired bit is the revocation list itself.
- TOTP via the PAM plugin. Because not all OpenVPN clients can handle the OTP field, this is implemented on top of the username + password fields. This isn’t an issue for the CLI client, but most GUI options aren’t smart enough to prompt for username + password + OTP. Given that this is the 3rd authentication factor besides the static key tls-auth and mutual TLS auth, the lack of password isn’t a problem.
Plugging pam_oath into OpenVPN is as easy as:
plugin /usr/lib/openvpn/openvpn-plugin-auth-pam.so "openvpn login USERNAME one-time PASSWORD"
Bear in mind that the actual plugin path may be different on your distribution. This is an Ubuntu example. The reneg-sec 0 option disables the re-keying which otherwise will drop the VPN connection unannounced. By default this is set to 3600 seconds and I had a fun time determining why my connection was dropping until I realised it happens periodically. Sometimes it wouldn’t even reconnect after such drop. The problem is caused by the fact that the re-keying can not happen with the credentials provided upon the initial connection since by design the TOTP has only a limited amount of time during which the OTP is valid.
The first bit after the .so, “openvpn”, is the name of the PAM module. The “login” argument gets the USERNAME value from the OpenVPN authentication dialogue, and the “one-time” argument gets the PASSWORD value from the OpenVPN authentication dialogue. The client configuration needs auth-user-pass to prompt for the username and OTP, besides the mutual TLS auth configuration options, ca, cert, and key.
The PAM module is configured in /etc/pam.d/openvpn and reads as follows:
auth requisite pam_oath.so usersfile=/etc/openvpn/users.oath window=5 digits=6
account required pam_permit.so
The first line of that module is where pam_oath is actually referenced. The “usersfile” path is where the credentials are stored i.e the “login” – checked against USERNAME and “one-time” seed – checked against PASSWORD. 6 digits is the typical TOTP used by most authenticator apps, although FreeOTP supports 8 digit TOTP as well. The window sets the search depth rather than being a reference for a time window.
The second line is just waiving by anybody who’s passing the OTP challenge. That line took the most effort to get there after a lot of groaning, swearing, and generally ranting about PAM and non-sensical error messages. Turns out, an actual account is required in the PAM flow after auth, but there isn’t one as there’s no account anywhere, whether the system itself or another authentication system, to match the OTP username. pam_permit must not be used without having a proper use case. This is one of those use cases. Otherwise, it may be a catastrophic security issue if used as a solution for every PAM problem. You have been warned!
The users.oath file itself needs to be properly protected as all the pre-shared OTP secrets live there. Basically root rw and nothing else. Even though my openvpn worker process, i.e the one taking in client connections, runs as nobody, that file is still readable/writable as the master process runs as root. Every time a successful challenge is passed, pam_oath updates that file.
The structure of users.oath is: Option User Prefix Seed. The manual is not brilliant, therefore I can’t tell why the prefix is just a dash, but for all intents and purposes, this is unused. The Arch wiki explains this better.
HOTP/T30/6 foo - 16a142bc8c34f2682f219dd9e75f76c3a7b7d62aad85047411bef4beb97514b8
TOTP is a particular case of HOTP i.e the counter is substituted for a non-decreasing time value, hence the Option reads HOTP/T30/6 which makes it the most common TOTP scheme – 30 seconds time step size with 6 digits OTP. Authy, Google Authenticator, FreeOTP, etc. support this. 6 digits is a commonly used number, not the mandated number of digits. The number of digits must match the digits value passed as argument to pam_oath.
“foo” is the username value. I have only tried alphanumeric values in there, therefore I can’t really tell what OATH tookit truly supports i.e whether dashes, dots, and underscores are supported. I know there’s groaning in some tooling when UNIX usernames contains characters like dot, hence mentioning this.
The “-” dash is the prefix.
The hex code is the pre-shared secret. RFC 4226 says:
The algorithm MUST use a strong shared secret. The length of the shared secret MUST be at least 128 bits. This document RECOMMENDs a shared secret length of 160 bits.RFC 4226 – Section 4, R6
That secret is hex encoded, which means it uses 2 characters for each byte. That makes the minimum length 32 hex chars to encode 128 bits.
For example, one can use this to generate secrets:
oathtool --verbose $(head -10 /dev/urandom | sha256sum | cut -b 1-64)
That line generates 256 bit secrets which is above the recommended value. While sha256sum itself generates 256 bit values, putting that through oathtool has more benefits. The hex secret value is simply reflecting the input hex secret.
Hex secret: 16a142bc8c34f2682f219dd9e75f76c3a7b7d62aad85047411bef4beb97514b8
Base32 secret: C2QUFPEMGTZGQLZBTXM6OX3WYOT3PVRKVWCQI5ARX32L5OLVCS4A====
Window size: 0
Start counter: 0x0 (0)
The interesting bits (pun not intended): the Hex secret and the Base32 secret. The hex encoded secret of the TOTP goes into users.oath. The Base32 encoded secret may be used to generate QR codes which may be easily read with an authenticator app on your phone, like Authy, Google Authenticator, FreeOTP, etc.
qrencode -o foo.png 'otpauth://totp/foo@openvpn?secret=C2QUFPEMGTZGQLZBTXM6OX3WYOT3PVRKVWCQI5ARX32L5OLVCS4A===='
The output of qrencode looks like:
You can scan that with an authenticator app to check that it works and check against:
oathtool --totp --digits 6 16a142bc8c34f2682f219dd9e75f76c3a7b7d62aad85047411bef4beb97514b8
It should read the same value provided the time is in sync on both your devices. You can even get future TOTP’s with the window argument:
oathtool --totp --digits 6 --window 2 16a142bc8c34f2682f219dd9e75f76c3a7b7d62aad85047411bef4beb97514b8
That prints the current TOTP plus the next 2.
Two things to keep in mind:
- Reusing the example secret which I have used here would be catastrophically stupid.
- Don’t scan QR codes when strangers on the Internet tell you to. While the one from above is legit, that may not always be the case.