OpenVPN provides a great foundation for setting up a virtual private network for SMEs. That being said, the typical configurations one finds online did not meet our security requirements. As a security company, we need to practice what we preach, and therefore, we spent some time while setting up our infrastructure to see how far we can push OpenVPN from a security perspective.
In this post, I would like to present a hardened OpenVPN configuration that provides the following:
-
Two-Factor Authentication (2FA). Login into the network requires not only a valid certificate and password but also a time-based one-time password, adding a second factor to the authentication.
-
Identity Binding. A certificate only allows logging into the system with the username that appears as a subject in the (signed) certificate, meaning, in particular, that the certificate of a less privileged user cannot be used in combination with stolen credentials of a priviliged user.
-
Static IP Assignment. Higher-privileged users will obtain fixed IP addresses bound to their usernames and thus, their certificates, and OpenVPN will not assign these IP addresses to less privileged users, nor will it accept manual IP address changes on the client side. This allows to create firewall rules on the VPN server to make administrative interfaces available only to a limited set of users.
We will now describe the implementation in greater detail.
Implementation
In the following, we describe in greater detail how this configuration can be realized, pointing you to the right configuration directives in particular.
Time-Based One Time Passwords (TOTP)
In order to log into the VPN, the user requires a client configuration file, which includes a private key in particular. This private key may or may not be password protected. Additionally, many configurations additionally perform username/password authentication on the server side, making it necessary for a client to have a corresponding system user on the OpenVPN server side as well as a corresponding password.
The reality is that people will typically store any static passwords required to connect to the network on the client for convenience, making the certificate a single factor in the authentication.
To increase the security of the configuration, we set out to replace the static password with a one-time password, generated using a time-based one time password algorithm (TOTP). These algorithms generate one-time passwords based on the current time and a secret, which may be stored in an Authenticator App on a phone, or even on a hardware security key such as a Yubikey. In effect, an attacker requires both access to the certificate, the password to decrypt the included private key, and the device that generates the one-time password or the secret it stores.
On a high level, this configuration can be achieved by making use of the pam_google_authenticator module for the Pluggable Authentication Modules framework, however, care must be taken in how this is configured.
First, the following line is added to the OpenVPN server configuration to ensure that PAM authentication is performed with our custom PAM configuration that calls pam_google_authenticator among other modules.
# /etc/openvpn/server/server.conf
...
plugin /usr/lib/openvpn/openvpn-plugin-auth-pam.so openvpn_2fa
This ensures that the configuration file /etc/pam.d/openvpn_2fa is used, which can be seen in the following listing.
auth requisite pam_exec.so /etc/openvpn/scripts/vpn_user_check.py
auth required pam_google_authenticator.so secret=/etc/openvpn/otp/${USER} user=vpn-otp
account required pam_permit.so
In this PAM configuration, we first call the external script /etc/openvpn/scripts/vpn_user_check.py in which I want to make absolutely sure that the username does not contain any weird characters and does in fact correspond to a user for which an OTP secret has been registered.
#!/usr/bin/env python3
import os
import re
import sys
# Configuration
OTP_DIR = "/etc/openvpn/otp"
# Regex: Only allow lowercase, uppercase, numbers, and underscores
USERNAME_PATTERN = r"^[a-zA-Z0-9_]+$"
def main():
# PAM provides the username via the PAM_USER environment variable
username = os.environ.get("PAM_USER")
if not username:
sys.exit(1)
# 1. Validate characters to prevent directory traversal (e.g., "../../etc/passwd")
if not re.match(USERNAME_PATTERN, username):
sys.exit(1)
# 2. Check if the file exists in the OTP directory
user_file_path = os.path.join(OTP_DIR, username)
if os.path.isfile(user_file_path):
sys.exit(0) # Success
else:
sys.exit(1) # Failure
if __name__ == "__main__":
main()
We use Python rather than Bash, because we do not trust ourselves with Bash, in particular when it comes to the various weird expansions it performs. Note that we apply an allow-list of characters that we believe we know how to handle rather than trying to define bad characters that cause trouble. Usernames are now limited to letters, numbers, and the underscore character, and in particular, slahes, backslashes and dots are not allowed to prevent directory traversal.
Now that we feel a bit more at ease with the username, we construct the path /etc/openvpn/otp/${USER} and pass it to the pam_google_authenticator module, which we run with the dedicated user vpn-otp, ensuring that if for some reason our username validation is flawed, we do not expose root permissions to read files.
pam_google_authenticator is a PAM module developed and maintained by the Google Security Team to provide support for the one-time-password mechanisms supported by Google Authenticator. Please note that it can also be used in combination with other authenticator apps (e.g., Microsoft Authenticator) and hardware tokens with support for the same algorithms.
We can now create the user test_user with the following command:
google-authenticator --time-based
--disallow-reuse
--force --rate-limit=3
--rate-time=30
--window-size=3
--secret=/etc/openvpn/otp/test_user
You will see both a QR code and the corresponding secret and can use this to create a new account on your yubikey or authenticator app. With this configuration - provided that you have a valid OpenVPN certificate - you can now log in using the username test_user and the one-time password, which changes as time progresses.
Binding the certificate to the username
We would like to now also ensure that the “subject” in the client certificate matches the username, meaning that a certificate issued for Alice can only be used to log in as Alice but not as Bob.
In this context, you will read about the OpenVPN directive username-as-common-name, however, this turns out not to be what we want. The directive enforces that the name in the certificate is ignored, and instead, the provided username is used, meaning that it doesn’t matter who we issued the certificate for. A corresponding common-name-as-username or subject-as-username does not exist.
Fortunately, we can make use of OpenVPN hooks to enforce this by ourselves. To acheve this, we add the following to our OpenVPN server configuration:
script-security 2
auth-user-pass-verify "/etc/openvpn/scripts/simple-verify.py" via-env
We can then write a script that verifies the username and common-name (subject in the certificate), which it receives via environment variables. The following shows a script that ensures that username and common-name match.
#!/usr/bin/env python3
import os
import sys
import syslog
syslog.openlog(ident="openvpn-verify", facility=syslog.LOG_AUTH)
# OpenVPN exports these automatically
common_name = os.environ.get('common_name')
username = os.environ.get('username')
# Logical check
if common_name and username and common_name == username:
syslog.syslog(syslog.LOG_INFO,
f"Access GRANTED: CN '{common_name}' matches Username '{username}'")
syslog.closelog()
sys.exit(0) # Allow
else:
syslog.syslog(syslog.LOG_WARNING,
f"Access DENIED: Mismatch found (CN: {common_name}, User: {username})")
syslog.closelog()
sys.exit(1) # Deny
With this configuration in place, a certificate issued for Alice can only be used to log in as Alice, and in particular, if a certificate of one of your road warriors is stolen, this does not allow logging in with administrative credentials, even if they are known.
Binding the username to an IP
Now that we know that users cannot just choose their usernames but can only use the usernames that are part of the signed certificate issued by the OpenVPN server, we can additionally bind users to IP addresses, ensuring that certain IP addresses are reserved for administrative staff. To achieve this, place the following additional lines into your OpenVPN configuration.
ifconfig-pool 10.8.1.2 10.8.1.199 255.255.255.0
client-config-dir /etc/openvpn/ccd
With this configuration, OpenVPN will check if a file named /etc/openvpn/ccd/{USER} exists, in which custom configurations for users can be placed. If no such file exists for a user, the default configuration ifconfig-pool 10.8.1.2 10.8.1.199 255.255.255.0 is used, which assigns IP addresses from the the pool 10.8.1.2-10.8.1.199. Adding the file /etc/openvpn/ccd/test_user with the following content now assures that test_user will always receive the IP 10.8.1.201, and in fact, is the only user that can obtain this IP address.
ifconfig-push 10.8.1.201 255.255.255.0
Finally, many guides recommend setting up network address translation (NAT) when routing from the VPN client network to the server network. This is problematic as it hides which particular client accesses which server behind the translation layer. We therefore recommend - and have set up - static routing rather than NAT, allowing clients to be distinguished on the server side by IP.
Conclusion
In many ways, we had expected to find that the functionality discussed in this blog post would be available by default in OpenVPN, and yet, our solution ultimately depended on custom scripts as well as configuration of PAM. This is understandable and possibly good design by OpenVPN as OpenVPN outsources authentication, focusing only on providing a VPN and nothing more. That being said, we wish a guide such as the one published here would have existed as we set out to configure OpenVPN. We hope somebody else finds this guide useful.