Edit May 3, 2018: Updated to use a GitHub gist to enable syntax highlighting and added a link to GitHub.
My work related to OpenVPN can be found at https://github.com/tidgubi/openvpn.
I’ve been working on updating my OpenVPN installation and decided I wanted to switch to username/password authentication, so I don’t have to deal with managing client certificates. Personally, I use head -c 32 /dev/urandom | base64
to generate my client passwords, so they are as strong as any certificate authentication. I wrote the following script to perform username/password authentication for OpenVPN using the auth-user-pass-verify
option and allow easy management of accounts.
This script uses /etc/openvpn/shadow as its username/password repository. The shadow file must be readable by the OpenVPN process; however, I suggest it is not writable by the OpenVPN process. I have the owner = root and group = openvpn, and permissions 640.
The script takes at least 1 second to verify a password. Passwords are salted with a unique 16 byte value and hashed 100,000 times.
The script works to verify that only one password verification is performed at any time.
Avoid misuse from special characters by Base64 encoding all values in the shadow file.
Usage:
- ./openvpn-auth.py <credential file> – how the script is invoked by OpenVPN to verify a username and password.
- ./openvpn-auth.py -a <username> [<password>] – add or update <username>. You will be prompted for a password if you do not provide one on the command line.
- ./openvpn-auth.py -g <username> [<password>] – generate a string that contians the encoded username, salt, and hashed password. This can be useful if you want to generate strings and add them to the script (list of strings in the shadow_contents variable).
- ./openvpn-auth.py -d <username> – delete the credentials for <username>.
- ./openvpn-auth.py -l – list the configured usernames.
openvpn-auth.py
#!/usr/bin/python # Username/Password Authentication Script for use with OpenVPN # Copyright (c) 2018 Kenji Yoshino https://www.tidgubi.com # This script is released under the Version 3 of the GNU General Public # License https://www.gnu.org/licenses/gpl-3.0.txt import sys import os from hashlib import pbkdf2_hmac from time import time, sleep from binascii import b2a_base64, a2b_base64 from getpass import getpass SHADOW_FILE="/etc/openvpn/shadow" LOCK_FILE="/dev/shm/openvpn-auth-lock" HASH="sha256" ITTERATIONS=100000 BASE_TIME=1.0 # set a minimum time in seconds for check function SALT_SIZE=16 MAX_UN_PW_LEN=512 MAX_SHADOW_FILE=16384 INVALID=1 VALID=0 # If SHADOW_FILE is None, shadow_contents can be configured as a list of strings # using the format: : for each line. Each value is base64 # encoded. Strings can be generated using the -g option shadow_contents=None def usage(): print "./openvpn-auth.py " print "./openvpn-auth.py -[g|a|d] [password]" print "./openvpn-auth.py -l" def getHash(salt, password): return b2a_base64(pbkdf2_hmac(HASH, password, salt, ITTERATIONS)).strip() def check(pw_file): while os.path.isfile(LOCK_FILE): sleep(0.1) with open(LOCK_FILE, 'a'): start=time() rtn=INVALID try: with open(pw_file, 'r') as f: username=b2a_base64(f.readline(MAX_UN_PW_LEN).rstrip("\r\n")).strip() + ":" password=f.readline(MAX_UN_PW_LEN).rstrip("\r\n") if len(f.readline(MAX_UN_PW_LEN)) > 0: return INVALID except Exception as e: print e return INVALID try: if SHADOW_FILE is not None: with open(SHADOW_FILE, 'r') as f: shadow_contents=f.readlines(MAX_SHADOW_FILE) for line in shadow_contents: if line.startswith(username): parts=line.split(":") if len(parts) != 3: break password=getHash(a2b_base64(parts[1]),password) if password == parts[2].strip(): rtn=VALID break except Exception as e: print e rtn=INVALID # make this function run in constant time t=BASE_TIME-(time()-start) if t > 0.0: sleep(t) for x in range(3): #try to remove the lock file 3 times try: os.remove(LOCK_FILE) break except Exception: continue return rtn def delete(username): if not os.path.isfile(SHADOW_FILE): return username=b2a_base64(username).strip() + ":" with open(SHADOW_FILE, 'r') as f: shadow_contents=f.readlines(MAX_SHADOW_FILE) with open(SHADOW_FILE, 'w') as f: for line in shadow_contents: if not line.startswith(username): f.write(line); f.truncate() def generate(username, password, add): start=time() while password is None: password=getpass("Enter a password for %s: " % username) verifypass=getpass("Verify password: ") if password != verifypass: print("Passwords do not match. Try again.\n") password=None salt=os.urandom(SALT_SIZE) encusername=b2a_base64(username).strip() password=getHash(salt,password) salt=b2a_base64(salt).strip() #TODO: warn the user if SHADOW_FILE is larger than max shawow length if add: delete(username) with open(SHADOW_FILE, 'a') as f: f.write("%s:%s:%s\n" % (encusername, salt, password)) else: print("%f seconds to compute hash" % (time()-start)) print("%s:%s:%s" % (encusername, salt, password)) def list(): try: if SHADOW_FILE is not None: with open(SHADOW_FILE, 'r') as f: shadow_contents=f.readlines(MAX_SHADOW_FILE) for line in shadow_contents: parts=line.split(":") if len(parts) != 3: continue print(a2b_base64(parts[0]) + "\n") except Exception as e: print e args=len(sys.argv) if args == 2: if sys.argv[1] == "-l": list() else: sys.exit(check(sys.argv[1])) elif args == 3 or args == 4: if sys.argv[1] == "-g" or sys.argv[1] == "-a": if sys.argv[1] == "-a" and SHADOW_FILE is None: print("Error: You must configure a SHADOW_FILE to use -a.\n") else: password=None if args == 4: password=sys.argv[3] generate(sys.argv[2], password, sys.argv[1] == "-a") elif sys.argv[1] == "-d": delete(sys.argv[2]) else: usage() else: usage() sys.exit(0)