Skip to content

Instantly share code, notes, and snippets.

@andrewlkho
Last active January 3, 2024 03:39
Show Gist options
  • Save andrewlkho/31341da4f5953b8d977aab368e6280a8 to your computer and use it in GitHub Desktop.
Save andrewlkho/31341da4f5953b8d977aab368e6280a8 to your computer and use it in GitHub Desktop.
Setting up a secure VPN with strongSwan on debian

With heightening concern regarding the state of internet privacy (fuelled in part by the passing of the Investigatory Powers Act in the UK), I have set up a VPN server on the virtual server I have hosted with Mythic Beasts. This uses strongSwan and certificate-based IKEv2 authentication.

Assumptions:

  • Debian Jessie server already set up and accessible via debian.example.com, a public IPv4 of 203.0.113.1 and a public IPv6 of 2001:db8::1
  • Client username of me
  • Clients are running the latest versions of macOS and iOS (Sierra and 10 respectively at the time of writing)
  • No need to support any other operating systems (although the setup is easily translated)

For automated deployment of a similar setup, albeit Ubuntu-based and using ansible for deployment, I recommend you take a look at Algo VPN. I used that project as a basis for my configuration.

Install strongSwan

Most of the rest of this guide assumes that you are on the server with root permissions, so:

% ssh debian.example.com
% sudo -s
$ apt-get install strongswan

Build the public key infrastructure

All of the certificates are stored in /etc/ipsec.d. Unfortunately, macOS Sierra does not seem to like PKI built using ECDSA certificates for reasons which are not clear to me so I have used 4096-bit RSA keys. The first step is to create a self-signed root CA certificate.

$ cd /etc/ipsec.d
$ ipsec pki --gen --type rsa --size 4096 --outform pem > private/ca.pem
$ ipsec pki --self --ca --lifetime 3650 --in private/ca.pem \
> --type rsa --digest sha256 \
> --dn "CN=debian.example.com" \
> --outform pem > cacerts/ca.pem

Next, generate a private key and signed certificate for the server. macOS requires the hostname/address in subjectAltName. Versions of Mac OS X prior to 10.7.4 required --flag ikeIntermediate but I have not added it here.

$ ipsec pki --gen --type rsa --size 4096 --outform pem > private/debian.pem
$ ipsec pki --pub --in private/debian.pem --type rsa |
> ipsec pki --issue --lifetime 3650 --digest sha256 \
> --cacert cacerts/ca.pem --cakey private/ca.pem \
> --dn "CN=debian.example.com" \
> --san debian.example.com --san 203.0.113.1 --san 2001:db8::1
> --flag serverAuth --outform pem > certs/debian.pem

The last certificate is for the client. You use one certificate for a single user on multiple devices hence I have only generated one.

$ ipsec pki --gen --type rsa --size 4096 --outform pem > private/me.pem
$ ipsec pki --pub --in private/me.pem --type rsa |
> ipsec pki --issue --lifetime 3650 --digest sha256 \
> --cacert cacerts/ca.pem --cakey private/ca.pem \
> --dn "CN=me" --san me \
> --flag clientAuth \
> --outform pem > certs/me.pem

Now that certificate generation is complete, you should remove the CA private key (private/ca.pem) from the server. You can either store it offline, or delete it entirely. If you choose the latter, you will not be able to sign any more peer certificates or revoke a certificate if a client were compromised. In the latter scenario, you would need to recreate a fresh PKI and disseminate to all peers. Failure to remove the CA private key means that anyone who gains access to it could MitM your TLS connections.

Generate an IPv6 ULA

Before getting started with configuring strongSwan, you'll want to generate an IPv6 unique local address block. The addresses are within the fc00::/7 block and contain a pseudo-random component according to RFC 4193. I've cobbled together a short script which will generate a ULA for you:

% curl -s https://raw.githubusercontent.com/andrewlkho/ulagen/master/ulagen.py | python
Prefix:       fdf3:5237:bf63::/48
First subnet: fdf3:5237:bf63::/64
Last subnet:  fdf3:5237:bf63:ffff::/64

Clearly your prefix will not be the same as that, so substitute fdf3:5237:bf63::/64 in the dnsmasq, strongSwan and iptables configuration below with what you have.

Optional: secure DNS with dnscrypt and DNSSEC

To prevent DNS cache poisoning, I run a local DNS server on debian which the VPN clients use. This consists of a dnscrypt-proxy client bound to [::1]:5353 which transparently proxies queries through to a dnscrypt-aware upstream resolver. dnscrypt-proxy itself is upstream of dnsmasq which provides features like DNSSEC validation and is what the VPN clients have configured as their DNS server.

Install dnscrypt-proxy.

$ sudo apt-get install libsodium-dev libldns-dev
$ cd /usr/local/src
$ curl -LO https://download.dnscrypt.org/dnscrypt-proxy/dnscrypt-proxy-1.9.2.tar.bz2
$ gpg --recv-keys 2B6F76DA
$ curl https://download.dnscrypt.org/dnscrypt-proxy/dnscrypt-proxy-1.9.2.tar.gz.sig | \
> gpg --verify - ./dnscrypt-proxy-1.9.2.tar.gz
$ tar xjf dnscrypt-proxy-1.9.2.tar.gz && cd dnscrypt-proxy-1.9.2
$ ./configure --prefix=/usr/local
$ make
$ make install

Create an unprivileged user.

$ useradd --system -d /usr/local/lib/dnscrypt-proxy -s /usr/sbin/nologin dnscrypt

Edit /usr/local/etc/dnscrypt-proxy.conf. You can choose whichever dnscrypt-enabled upstream server you want; I have chosen to use DNSCrypt.eu of which their Netherlands server has the fewest hops to debian. dnscrypt-proxy is bound to ::1 port 5353.

ProviderName    2.dnscrypt-cert.resolver1.dnscrypt.eu
ProviderKey     67C0:0F2C:21C5:5481:45DD:7CB4:6A27:1AF2:EB96:9931:40A3:09B6:2B8D:1653:1185:9C66
ResolverAddress [2a00:d880:3:1::a6c1:2e89]:443
Daemonize       no
LocalAddress    [::1]:5353
LocalCache      off
User            dnscrypt
EphemeralKeys   on
BlockIPv6       no

Copy across the systemd service (amending the paths) and start dnscrypt-proxy.

$ sed -e 's:^ExecStart=.*$:ExecStart=/usr/local/sbin/dnscrypt-proxy /usr/local/etc/dnscrypt-proxy.conf:' \
> /usr/local/src/dnscrypt-proxy-1.9.2/dnscrypt-proxy.service > /etc/systemd/system/dnscrypt-proxy.service
$ systemctl enable dnscrypt-proxy.service
$ systemctl start dnscrypt-proxy

You can check that it works by querying resolver.dnscrypt.org which returns the address of the nameserver you are using.

$ dig @::1 -p 5353 +short -x $( dig @::1 -p 5353 +short resolver.dnscrypt.org A )
resolver1.dnscrypt.eu

The next step is to install dnsmasq.

$ apt-get install dnsmasq

dnsmasq should bind to ::1 and also a unique local address on the loopback interface. Use the ULA prefix generated previously, but a different subnet, by putting the following into /etc/network/interfaces.

auto lo:1
iface lo:1 inet6 static
address fdf3:5237:bf63:1::1
netmask 64
autoconf 1

Then bring it up with ifup lo:1. Edit /etc/dnsmasq.conf.

no-resolv
no-hosts
server=::1#5353
listen-address=::1
listen-address=fdf3:5237:bf63:1::1
bind-interfaces

dnssec
trust-anchor=.,19036,8,2,49AAC11D7B6F6446702E54A1607371607A1A41855200FD2CE1CDDE32F24E8FB5
dnssec-check-unsigned

bogus-priv
domain-needed
stop-dns-rebind
rebind-localhost-ok

cache-size=2000

Restart dnsmasq.

$ systemctl restart dnsmasq

Configure strongSwan

Disable any plugins that you do not need.

$ plugins=(aes gcm hmac kernel-netlink nonce openssl pem pgp \
> pkcs12 pkcs7 pkcs8 pubkey random revocation sha2 socket-default stroke x509)
$ cd /etc/strongswan.d/charon
$ for x in *.conf; do
>   if [[ ${plugins[(i)${x%.conf}]} -le ${#plugins} ]]; then
>     sed -E 's/^([[:space:]]+)load = no/\1load = yes/' ${x}
>   else
>     sed -E 's/^([[:space:]]+)load = yes/\1load = no/' ${x}
>   fi
> done

Copy the following /etc/ipsec.conf (don't forget to change the ULA).

config setup
    uniqueids=never

conn %default
    keyexchange=ikev2
    ike=aes128gcm16-sha2_256-prfsha256-ecp256!
    esp=aes128gcm16-sha2_256-ecp256!
    fragmentation=yes
    rekey=no
    compress=yes
    dpdaction=clear

    left=%any
    leftauth=pubkey
    leftid=debian.example.com
    leftcert=debian.pem
    leftsendcert=always
    leftsubnet=0.0.0.0/0,::/0

    right=%any
    rightauth=pubkey
    rightsourceip=172.16.0.0/24,fdf3:5237:bf63::/64
    rightdns=fdf3:5237:bf63:1::1

conn ikev2-pubkey
    auto=add

A few points to note on that:

  • I have only allowed a single IKE cipher suite. If you want to support clients other than macOS and iOS, you may need to adjust this.
  • macOS/iOS will only propose 128-bit AES-GCM if you configure the VPN using a .mobileconfig configuration profile. If you use the GUI to configure the VPN on the client then you will need to use ike=aes256-sha2_256-prfsha256-ecp256! and esp=aes256-sha2_256-ecp256!.
  • I have used the local DNS server bound to the loopback interface.

Next, copy the following /etc/ipsec.secrets.

: RSA debian.pem

Then restart strongswan.

$ systemctl restart strongswan

Enable packet forwarding

Configure the kernel to enable packet forwarding by putting the following lines in /etc/sysctl.conf.

net.ipv4.ip_forward = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv6.conf.all.forwarding = 1
net.ipv6.conf.eth0.accept_ra = 2

The penultimate line enables forwarding for IPv6, but has the unfortunate side-effect of therefore not accepting router advertisements. These are needed for SLAAC, without which the server cannot get routing information (at least, with how Mythic Beasts configure their virtual servers). This would break IPv6 networking on the server. The last line therefore adjusts this for the interface eth0.

Load the settings.

% sysctl -p

Configure iptables

Here is a snippet of my /etc/iptables/rules.v4.

*filter
[...]
-A INPUT -p esp -j ACCEPT
-A INPUT -p ah -j ACCEPT
-A INPUT -p udp -m multiport --dports 500,4500 -j ACCEPT
-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A FORWARD -m conntrack --ctstate NEW -s 172.16.0.0/24 -m policy --pol ipsec --dir in -j ACCEPT
COMMIT

*nat
[...]
-A POSTROUTING -s 172.16.0.0/24 -m policy --pol none --dir out -j MASQUERADE
# If you have a static IP address you can do:
# -A POSTROUTING -s 172.16.0.0/24 -m policy --pol none --dir out -j SNAT --to-source 203.0.113.1
COMMIT

Here is a snippet of my /etc/iptables/rules.v6 (don't forget to change the ULA).

*filter
[...]
-A INPUT -p esp -j ACCEPT
-A INPUT -m ah -j ACCEPT
-A INPUT -p udp -m multiport --dports 500,4500 -j ACCEPT
-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A FORWARD -m conntrack --ctstate NEW -s fdf3:5237:bf63::/64 -m policy --pol ipsec --dir in -j ACCEPT

# If you are running a local DNS server as above
-A INPUT -p udp --dport 53 -d fdf3:5237:bf63:1::1 -j ACCEPT
COMMIT

*nat
[...]
-A POSTROUTING -s fdf3:5237:bf63::/64 -m policy --pol none --dir out -j MASQUERADE
COMMIT

Configure the clients

To configure the macOS and iOS clients, you need to generate a configuration profile. I wrote a shell script to do this (mobileconfiggen.sh) which is attached to this gist. As well as the VPN settings, this pulls in the root CA certificate and client key/certificate pair.

Aside from the variables at the start of the script, you will probably also want to amend the OnDemandRules array. This is an array of dictionaries, each of which specifies whether or not your iOS device will automatically connect to the VPN in certain scenarios. The rules I have specified mean that unless you are on a mobile connection or using one of the pre-specified trusted WiFi networks ($TRUSTED_SSIDS) it will connect to the VPN. If you want to edit this, then take a look at Apple's Configuration Profile Reference.

The script takes no arguments, and will output the configuration profile to STDOUT.

$ ./mobileconfiggen.sh > debian.mobileconfig

On macOS, you can install this with:

$ profiles -I -F debian.mobileconfig

On iOS, the easiest way to install it is to send the file to the device over AirDrop.

Conclusion

That's it. Reboot your server to check it all comes up automatically and you should be done. A few links which were helpful to me:

If you have any suggestions for how this guide or setup could be improved, then please let me know.

#!/bin/zsh
CLIENT="me"
SERVER="debian"
FQDN="debian.example.com"
CA="ca"
# WiFi SSIDs that do not require automatic connection to VPN on network change
TRUSTED_SSIDS=("SSID1" "SSID2")
PAYLOADCERTIFICATEUUID=$( cat /proc/sys/kernel/random/uuid )
PKCS12PASSWORD=$( cat /proc/sys/kernel/random/uuid )
cat << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadDisplayName</key>
<string>${SERVER} VPN</string>
<key>PayloadIdentifier</key>
<string>${(j:.:)${(Oas:.:)FQDN}}</string>
<key>PayloadUUID</key>
<string>$( cat /proc/sys/kernel/random/uuid )</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>${SERVER} VPN</string>
<key>PayloadDescription</key>
<string>Configure VPN</string>
<key>UserDefinedName</key>
<string>${SERVER}</string>
<key>VPNType</key>
<string>IKEv2</string>
<key>IKEv2</key>
<dict>
<key>RemoteAddress</key>
<string>${FQDN}</string>
<key>RemoteIdentifier</key>
<string>${FQDN}</string>
<key>LocalIdentifier</key>
<string>${CLIENT}</string>
<key>AuthenticationMethod</key>
<string>Certificate</string>
<key>PayloadCertificateUUID</key>
<string>${PAYLOADCERTIFICATEUUID}</string>
<key>CertificateType</key>
<string>RSA</string>
<key>ServerCertificateIssuerCommonName</key>
<string>${FQDN}</string>
<key>EnablePFS</key>
<integer>1</integer>
<key>IKESecurityAssociationParameters</key>
<dict>
<key>EncryptionAlgorithm</key>
<string>AES-128-GCM</string>
<key>IntegrityAlgorithm</key>
<string>SHA2-256</string>
<key>DiffieHellmanGroup</key>
<integer>19</integer>
</dict>
<key>ChildSecurityAssociationParameters</key>
<dict>
<key>EncryptionAlgorithm</key>
<string>AES-128-GCM</string>
<key>IntegrityAlgorithm</key>
<string>SHA2-256</string>
<key>DiffieHellmanGroup</key>
<integer>19</integer>
</dict>
<key>OnDemandEnabled</key>
<integer>1</integer>
<key>OnDemandRules</key>
<array>
<dict>
<key>InterfaceTypeMatch</key>
<string>WiFi</string>
<key>SSIDMatch</key>
<array>
`for x in ${TRUSTED_SSIDS}; echo " <string>$x</string>"`
</array>
<key>Action</key>
<string>Disconnect</string>
</dict>
<dict>
<key>InterfaceTypeMatch</key>
<string>Cellular</string>
<key>Action</key>
<string>Disconnect</string>
</dict>
<dict>
<key>Action</key>
<string>Connect</string>
</dict>
</array>
</dict>
<key>PayloadType</key>
<string>com.apple.vpn.managed</string>
<key>PayloadIdentifier</key>
<string>com.apple.vpn.managed.${SERVER}</string>
<key>PayloadUUID</key>
<string>$( cat /proc/sys/kernel/random/uuid )</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<dict>
<key>PayloadDisplayName</key>
<string>${CLIENT}.p12</string>
<key>PayloadDescription</key>
<string>Add PKCS#12 certificate</string>
<key>PayloadCertificateFileName</key>
<string>${CLIENT}.p12</string>
<key>Password</key>
<string>${PKCS12PASSWORD}</string>
<key>PayloadContent</key>
<data>
$( openssl pkcs12 -export -inkey /etc/ipsec.d/private/${CLIENT}.pem -in /etc/ipsec.d/certs/${CLIENT}.pem -name "${CLIENT}" -certfile /etc/ipsec.d/cacerts/${CA}.pem -password pass:${PKCS12PASSWORD} | base64 )
</data>
<key>PayloadType</key>
<string>com.apple.security.pkcs12</string>
<key>PayloadIdentifier</key>
<string>com.apple.security.pkcs12.${CLIENT}</string>
<key>PayloadUUID</key>
<string>${PAYLOADCERTIFICATEUUID}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<dict>
<key>PayloadDisplayName</key>
<string>${SERVER} CA</string>
<key>PayloadDescription</key>
<string>Add CA root certificate</string>
<key>PayloadCertificateFileName</key>
<string>ca.pem</string>
<key>PayloadContent</key>
<data>
$( cat /etc/ipsec.d/cacerts/${CA}.pem | base64 )
</data>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadIdentifier</key>
<string>com.apple.security.root.${SERVER}</string>
<key>PayloadUUID</key>
<string>$( cat /proc/sys/kernel/random/uuid )</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
</dict>
</plist>
EOF
@webvictim
Copy link

webvictim commented Sep 7, 2020

I had this setup working previously (back in January 2020) but it seems to have stopped working on MacOS 10.15.6 and iOS 13.6.1. This manifests as "User authentication failed" in the iOS GUI when attempting to connect, and various errors in logs from NEIKEV2Provider saying "Authentication: Certificate authentication data could not be verified". There are no errors in StrongSwan logs - as far as it's concerned, the connection is fine.

It would seem to be complaining about not being able to build a full chain with the self-signed cert. I've tried regenerating the root CA and all certs then redeploying, but nothing seems to work. I can't configure the connection manually via the iOS GUI either. Anyone else experiencing a similar issue and/or found a solution?

Edit: This turned out to be a GCC bug, partly due to the fact that I'm running Fedora rather than Debian. There's a bug with the way that the pki tool gets compiled - see https://wiki.strongswan.org/issues/3249. My CA cert had invalid key usage flags as a result of this bug and it seems that as part of Apple's new certificate checking process, the key usage flags were checked and found to be invalid. This appears in MacOS/iOS logs as Trust evaluate failure: [root KeyUsage] under NEIKEv2Provider.

After I reran the pki commands on a Debian-based distribution everything now works as expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment