Lead Image © Orlando Rosu, 123rf.com

Lead Image © Orlando Rosu, 123rf.com

Manage OpenVPN keys with Easy-RSA

Key Cabinet

Article from ADMIN 53/2019
By
The Easy-RSA tool that comes with OpenVPN provides trouble-free open source PKI management.

At OpenVPN seminars, participants arrive with their own wishes and ideas. Although they are satisfied with the OpenVPN support for bandwidth limitation, simple high availability, and flexible traffic management, for example, one topic remains unclear and concerns all VPNs: How does the admin create and manage a simple secure sockets layer (SSL) public key infrastructure (PKI) for many users without spending a large amount of cash on service providers or proprietary software? The ideal solution would be open source – free of licensing costs and similar complications and definitely not cloud- or web-service-based – in which the use of self-signed certificates is not a problem.

In-House Label

OpenVPN [1] traditionally relies on Easy-RSA [2], of which version 2 is widely used. Many, especially the Debian-based, distributions install it along with openvpn – one exception being Ubuntu, which only offers easy-rsa starting with Cosmic Cuttlefish (Ubuntu version 18.10) [3].

The successor, Easy-RSA 3.0 [4], has been available for years and simplifies a few things but is not that different from version 2, on which most solutions and setups are still based. Both versions have one thing in common: They come without a fancy GUI, but as plain vanilla command-line tools, which is a bit unusual for point-and-click GUI users and many admins.

Although Open CA, XCA, and TinyCA also are open source tools for making PKI administration easier, none of them has had any genuine success. The topic itself seems to be too complex and too prone to error, with a workflow that users and even admins just cannot comprehend.

OpenVPN founder James Yonan of OpenVPN Technologies obviously also recognized this problem. Among other things, the software is supported by a web front end that eases the generation and distribution of certificates. However, this is only true for the company's own OpenVPN product, which the company will be releasing as OpenVPN 3.0 at some point.

Armed with the knowledge from my OpenVPN seminars, I can safely say that creating the first set of server, client, and CA certificates and keys are the most difficult tasks, regardless of the tool used, because in the end, OpenSSL always works in the background with command lines like that shown in Listing 1.

Listing 1

OpenSSL from Hell

openssl x509
  -req
  -CA ca.crt
  -CAkey ca.key
  -set_serial 0x$(openssl rand -hex 16)
  -days 3650
  -extfile <(echo -e "keyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage= serverAuth")
  -in server.csr
  -out server.crt

Because no admin wants to type this stuff on a regular basis, many instructions and scripts simplify the process without significantly increasing the error rate. Easy-RSA is also basically only an intelligent OpenSSL wrapper.

I asked friends and acquaintances in the Linux and security world whether this would work for thousands of users. It does, replied – among others – the experienced sys admins at Berlin's CharitÈ [5], Germany's largest hospital.

The CharitÈ admins have extended Easy-RSA by adding a few scripts and currently manage 17,000 users. As Ralf Hildebrandt, Senior Network Engineer at CharitÈ and often a helpful point of contact, explained: "We use Easy-RSA on the VPN server and automatically generate user certificates in the form <Username>.vpn.charite.de. If a user leaves, we revoke the certificate after a certain grace period. We get the login data from LDAP."

Easy-RSA 3

In openSUSE Tumbleweed, the command

zypper in easy-rsa

installs version 3.0 of the software. You can accomplish the same with Apt or Aptitude, Yum, or the package manager of your choice on other distributions. In most cases, however, this command will only install an earlier version.

After the install, you find the configuration files in the /etc/easy-rsa/ directory; working as root in this directory, call all the following commands.

easyrsa init-pki
easyrsa gen-dh
easyrsa build-client-full <Unique_DN_of_Client_Certificate> nopass

Just these few steps take you to your own PKI. The first command initializes the PKI, deletes all previously entered data, if necessary, and creates a Certificate Authority (CA). Watch out, this completely resets the PKI. If you want to make your work easier, edit the vars file and add your data to save some typing later. The second command creates a Diffie-Hellman key for the initial key exchange between unknown, untrusted partners, and the third creates a client certificate that provides access to the VPN without a password. For example, if the last command reads

easyrsa build-client-full Client234 nopass

then Client234 appears as a distinguished name (DN) entry in the client certificate; OpenVPN uses this name for numerous practical functions. Similarly,

easyrsa build-server-full <DN> nopass

generates a server certificate without password protection. Now you can find certificates and private and public Diffie-Hellman keys in the directories under /etc/easy-rsa/pki (Figures 1 and 2).

Figure 1: Easy-RSA creates certificates and private and public Diffie-Hellman keys.
Figure 2: Credentials created with build-server-full.

You will want to isolate this PKI structure from the OpenVPN server – not least because these folders contain all the private keys for all the certificates. Ideally, Easy-RSA should run on an isolated system without a connection to the Internet. Although this process might seem archaic and cannot always be put into practice, losing the private key of the PKI (ca.key) opens up the whole infrastructure to attackers.

By the way,

easyrsa export-p12 <DN>

exports a key pair in PKCS12 format. The

easyrsa gen-req <DN>
easyrsa sign-req <DN>

commands give Easy-RSA full-fledged PKI management, and the

build-client-full
build-server-full

commands handle the request and sign procedure automatically, without requiring any intervention on the part of the admin.

The scripts CharitÈ uses are based on Easy-RSA 2.2.2-2, which comes with the Debian version they use in Berlin. However, they can be put together quite quickly for version 3. Further details (see Listing 2 with English translations) were provided by Hildebrandt: "Our script is named /opt/openvpn/scripts/generate_certs_for_active_users.py; it generates certificates for the active users on the VPN system starting in lines 47 and 60 if no certificate is available yet. A search against /etc/easy-rsa/ with ll*.crt | wc -l currently finds 16,849 user certificates."

Listing 2

generate_certs_for_active_users.py

01 #!/usr/bin/python
02 import urllib, json, os, subprocess, sys
03
04 url = "http://vpnapi.charite.de/vpn/user/status/activevpn"
05 response = urllib.urlopen(url);
06 data = json.loads(response.read())
07
08 for userentry in data:
09     if userentry.get('active'):
10         username = userentry.get('username')
11
12         if os.path.isfile( "/opt/openvpn/ca/keys/" + username + ".crt"):
13             # print "Cert for " + username + " exists"
14             a=1
15         else:
16
17             # get user details
18             detailsurl = "http://vpnapi.charite.de/vpn/user/details/" + username
19             response = urllib.urlopen(detailsurl);
20             detaildata = json.loads(response.read())
21             useremail = detaildata.get('mail')
22             if not useremail:
23                 print "User " + username + " has no Email address in LDAP"
24                 # Import smtplib for the actual sending function
25                 import smtplib
26
27                 # Import the email modules we'll need
28                 from email.mime.text import MIMEText
29
30                 # Create a text/plain message
31                 msg = MIMEText("User " + username + " has no Email address in LDAP")
32
33                 [...]
34                 msg['Subject'] = 'User %s has no Email address in LDAP' % username
35                 msg['From'] = 'vpn@charite.de'
36                 msg['To'] = 'vpn@charite.de'
37
38                 [...]
39                 s = smtplib.SMTP('localhost')
40                 s.sendmail('vpn@charite.de', 'vpn@charite.de', msg.as_string())
41                 s.quit()
42                 break
43
44             # end get user details
45
46             print "Need to generate cert for " + username
47             p = subprocess.Popen("/opt/openvpn/ca/build-key-auto " + username, bufsize=1, cwd="/opt/openvpn/ca", stdout=subprocess.PIPE, close_fds=False, universal_newlines=True, shell=True)
48             if p.wait:
49                 ret = p.wait()
50             else:
51                 ret = p.returncode
52             if (ret != 0):
53                 # if not OK
54                 if p.stdout:
55                     print "Output was:"
56                     print p.stdout.readlines()
57             else:
58                 # if OK
59                 print "Send Certificate!"
60                 p = subprocess.Popen("/opt/openvpn/scripts/send_certs_and_config.py " + username, bufsize=1, cwd="/opt/openvpn/ca", stdout=subprocess.PIPE, close_fds=False, universal_newlines=True, shell=True)
61                 if p.wait:
62                     ret = p.wait()
63                 else:
64                     ret = p.returncode
65                 if (ret != 0):
66                     if p.stdout:
67                         print "Output was:"
68                         print p.stdout.readlines()
69                         # if not OK

The small collection contains two longer and three short scripts (available online [6]):

  • generate_certs_for_active_users.py creates a certificate for all existing users without a certificate (Listing 2).
  • send_certs_and_config.py (Listing 3) signs them with S/MIME and mails to the VPN users.
  • revoke_remove_cert_without_user (Listing 4) is applied when a user leaves.
  • checkCertWithoutUser.pl and revoke_and_delete (called in Listing 4, line 2) [6] get rid of certificates after some time has elapsed (Figure 3).
Figure 3: Certificates are revoked with the revoke_and_delete script.

The next step is to take a detailed look at the listings.

Certs for Active Users

To create a certificate for the active users of the VPN system, generate_certs_for_active_users.py (Listing 2) uses the JSON API to query which users are active. It iterates over the users and checks whether /opt/openvpn/ca/keys contains a .crt file with the name of the respective user. If this is not the case, it generates a certificate with the /opt/openvpn/ca/build-key-auto username script provided with Easy-RSA. Then, send_certs_and_config.py (Listing 3) mails the signed certificates to the users. The VPN server only needs to recognize the CA; the PKI work can and should be done elsewhere.

Listing 3

send_certs_and_config.py

001 #!/usr/bin/python
002 # -*- coding: utf-8 -*-
003 import urllib, json, subprocess, argparse, re, sys
004 import [...]
005
006 from M2Crypto import BIO, Rand, SMIME
007 from [...]
008
009 [...]
010 ssl_key  = '/etc/ssl/private/mailcert-vpn.charite.de.key'
011 ssl_cert = '/etc/ssl/certs/mailcert-vpn.charite.de.crt'
012
013
014 def send_mail_ssl(sender, to, subject, text, files=[], attachments={}, bcc=[]):
015     """
016     Sends SSL signed mail
017     [...]
018     """
019
020     if isinstance(to, str):
021         to = [to]
022
023     # create multipart message
024     msg = MIMEMultipart()
025
026     # attach message text as first attachment
027     msg.attach( MIMEText(text, "plain", "utf-8") )
028
029     # attach files to be read from file system
030     for file in files:
031         part = MIMEBase('application', "octet-stream")
032         part.set_payload( open(file,"rb").read() )
033         Encoders.encode_base64(part)
034         part.add_header('Content-Disposition', 'attachment; filename="%s"'
035              % os.path.basename(file))
036         msg.attach(part)
037
038     # attach files read from dictionary
039     for name in attachments:
040         part = MIMEBase('application', "octet-stream")
041         part.set_payload(attachments[name])
042         Encoders.encode_base64(part)
043         part.add_header('Content-Disposition', 'attachment; filename="%s"' % name)
044         msg.attach(part)
045
046     # put message with attachments into SSL' I/O buffer
047     msg_str = msg.as_string()
048     buf = BIO.MemoryBuffer(msg_str)
049
050     # load seed file for PRNG
051     Rand.load_file('/tmp/randpool.dat', -1)
052
053     smime = SMIME.SMIME()
054
055     # load certificate
056     smime.load_key(ssl_key, ssl_cert)
057
058     # sign whole message
059     p7 = smime.sign(buf, SMIME.PKCS7_DETACHED)
060
061     # create buffer for final mail and write header
062     out = BIO.MemoryBuffer()
063     out.write('From: %s\n' % sender)
064     out.write('To: %s\n' % COMMASPACE.join(to))
065     out.write('Date: %s\n' % formatdate(localtime=True))
066     out.write('Subject: %s\n' % subject)
067     out.write('Auto-Submitted: %s\n' % 'auto-generated')
068     out.write('X-Auto-Response-Suppress: %s\n' % 'OOF')
069
070     # convert message back into string
071     buf = BIO.MemoryBuffer(msg_str)
072
073     # append signed message and original message to mail header
074     smime.write(out, p7, buf)
075
076     # load save seed file for PRNG
077     Rand.save_file('/tmp/randpool.dat')
078
079     # extend list of recipients with bcc addresses
080     to.extend(bcc)
081
082     # finally send mail
083     p = Popen(["/usr/sbin/sendmail", "-fvpn@charite.de", "-oi", COMMASPACE.join(to)], stdin=PIPE, universal_newlines=True)
084     p.communicate(out.read())
085
086 # BEGINNING OF SCRIPT
087 if not os.geteuid() == 0:
088     sys.exit('Script must be run as root')
089
090 # Parse command-line arguments
091 parser = argparse.ArgumentParser(description='Sends VPN configuration file to users')
092
093 parser.add_argument('userinput', help='<username|mailaddress>')
094 parser.add_argument('mailaddress', nargs='?', help='<Destination mail address - default is the user's request email address>')
095 args = parser.parse_args()
096
097 [...]
098
099 is_mail = re.compile('^.+?\@.+')
100 if is_mail.match(args.userinput):
101     #print args.userinput + " is an email address"
102     url = "http://vpnapi.charite.de/vpn/user/detailsbyemail/" + args.userinput
103 else:
104     #print args.userinput + " is a username"
105     url = "http://vpnapi.charite.de/vpn/user/details/" + args.userinput
106
107 #response = urllib.urlopen(url);
108 response = urllib.urlopen(url, proxies={});
109 data = json.loads(response.read())
110
111 username = data.get('username')
112 active = data.get('vpnActive')
113
114 if args.mailaddress:
115     if is_mail.match(args.mailaddress):
116         #print args.mailaddress + " is an email address"
117         email = args.mailaddress
118     else:
119         print args.mailaddress + " is an email address!"
120         sys.exit(1)
121 else:
122     email = data.get('mail')
123
124 if not username:
125     print "Username does not exist"
126     sys.exit(1)
127
128 if not active:
129     print "User is not active"
130     sys.exit(1)
131
132 if not email:
133     print "Email is blank"
134     sys.exit(1)
135
136 # Encode email address if not blank!
137 email = email.encode('ascii','ignore')
138
139 #print username, email
140
141 if os.path.isfile( "/opt/openvpn/ca/keys/" + username + ".crt"):
142     #print "Cert for " + username + " exists"
143
144     # Read in the text portion of the mail
145     with open('/opt/openvpn/etc/sendcertagain-mailbody.txt', 'r') as myfile:
146         text=myfile.read()
147
148     # Set filenames for the attachment
149     configfilename="charite-" + username + ".ovpn"
150
151     # Define key material paths
152     keyfilename="/opt/openvpn/ca/keys/" + username + ".key";
153     crtfilename="/opt/openvpn/ca/keys/" + username + ".crt";
154     cafilename="/opt/openvpn/ca/keys/ca.crt";
155
156     # Open key material
157     with open('/opt/openvpn/etc/charite.ovpn.template', 'r') as myfile:
158         configtemplatefile = myfile.read()
159     with open(cafilename, 'r') as myfile:
160         cafile = myfile.read()
161     with open(keyfilename, 'r') as myfile:
162         keyfile = myfile.read()
163     with open(crtfilename, 'r') as myfile:
164         crtfile = myfile.read()
165
166     sender = str(Header("CharitÈ VPN administrator", "utf8")) + " <vpn@charite.de>"
167     subject = Header("Configuration file to your CharitÈ VPN access","utf8")
168
169     configfile = configtemplatefile + "<ca>\n" + cafile + "</ca>\n<key>\n" + keyfile + "</key>\n<cert>\n" + crtfile + "</cert>\n"
170     send_mail_ssl(sender, email, subject, text, files=[], attachments={configfilename: configfile} )
171     print "Configuration file was sent to " + email + "."
172
173 else:
174     print "User " + username + " has no certificate"
175     sys.exit(1)
176 if os.path.isfile( "/opt/openvpn/ca/keys/" + username + ".crt"):
177     #print "Cert for " + username + " exists"
178
179     # Read in the text part of the mail
180     with open('/opt/openvpn/etc/sendcertagain-mailbody.txt', 'r') as myfile:
181         text=myfile.read()
182
183     # Set attachment filenames
184     configfilename="charite-" + username + ".ovpn"
185
186     # Define key material paths
187     keyfilename="/opt/openvpn/ca/keys/" + username + ".key";
188     crtfilename="/opt/openvpn/ca/keys/" + username + ".crt";
189     cafilename="/opt/openvpn/ca/keys/ca.crt";
190
191     # Open key material
192     with open('/opt/openvpn/etc/charite.ovpn.template', 'r') as myfile:
193         configtemplatefile = myfile.read()
194     with open(cafilename, 'r') as myfile:
195         cafile = myfile.read()
196     with open(keyfilename, 'r') as myfile:
197         keyfile = myfile.read()
198     with open(crtfilename, 'r') as myfile:
199         crtfile = myfile.read()
200
201     sender = str(Header("CharitÈ VPN Administrator", "utf8")) + " <vpn@charite.de>"
202     subject = Header("Configuration file to your CharitÈ VPN access","utf8")
203
204     configfile = configtemplatefile + "<ca>\n" + cafile + "</ca>\n<key>\n" + keyfile + "</key>\n<cert>\n" + crtfile + "</cert>\n"
205     send_mail_ssl(sender, email, subject, text, files=[], attachments={configfilename: configfile} )
206     print "Configuration file was sent to " + email + "."
207
208 else:
209     print "User " + username + " has no Certificate"
210     sys.exit(1)

Buy this article as PDF

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy ADMIN Magazine

SINGLE ISSUES
 
SUBSCRIPTIONS
 
TABLET & SMARTPHONE APPS
Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

comments powered by Disqus