Join GitHub today
GitHub is home to over 28 million developers working together to host and review code, manage projects, and build software together.Sign up
Two Factor Authentication with TunSafe
Adding Two-Factor Authentication to TunSafe
At TunSafe, we always try to find ways to keep our customers' WireGuard tunnels more secure. We don't like the fact that the WireGuard configuration file is all you need to connect to someone else's internal network. We think there should be some additional security measure in addition to this.
We're strong advocates of two-factor authentication and think that's an important feature of VPN deployments, especially in enterprise scenarios. We've been trying to find ways to add TOTP to a WireGuard tunnel. Having support for this means you can use one of the many authenticatior apps on your phone as an extra layer of security.
A straightforward way to add two-factor authentication on top of the existing WireGuard infrastructure is to first establish the tunnel as usual, and then having firewall rules that block all network packets. Then the peer could communicate with some internal host and exchange the two-factor information. This opens up the firewall and the peer can now communicate.
After a timeout, such as 1 hour, if no traffic has been exchanged, the firewall is re-enabled and a new two-factor exchange is required to open up the firewall again.
This sounds easy enough, however, it has one serious drawback. If an attacker connects with a config file during the time where the firewall is already open, then the attacker is able to access the network, WITHOUT providing any two-factor information. This is because the legitimate peer already opened up the firewall, and WireGuard has no way of differentiating the handshake of the attacker from the handshake of the legitimate peer. There's no state shared across two handshake messages, and nothing to associate the two-factor information with, so WireGuard has no idea that it's the attacker that's now establishing a tunnel.
To solve this, we've been adding some TunSafe extensions to the protocol. The first thing is a session ID. This is an ID that's unique for each connected peer. If someone else connects with the same config file, that session ID will be different. In the handshake response, the Server will provide the Client with a session ID. The Client proves that it knows the session ID by including a hash of it in all future handshake initiation packets, until the client is restarted.
When a Client connects to a server, and the Server has two-factor enabled for that client peer, then instead of setting up the WireGuard keypair as usual, the Server instead responds with a TunSafe specific TokenRequest message. When the Client sees this, it asks the user for the two-factor token. In the next handshake initiation packet, it will include the two-factor token in the TokenReply response, and also the session ID hash.
When the server sees the TokenReply, it will check if the supplied token is valid by using the standard TOTP algorithm. If valid, the handshake is accepted. There's no changes required to WireGuard's handshaking mechanism except an added field, so the security guarantees of WireGuard should still hold. In all future handshakes, the session ID hash will be included, and through this the server knows that this peer is legitimate.
For peers that don't have two-factor enabled, the handshake is completely unchanged. It's only clients that want to use two-factor authentication that need to be upgraded.
When using two-factor authentication, even if both peer private keys leak, there's no way whatsoever to connect to the tunnel without knowing the two-factor token.
In the terminology we use the words Client and Server. Client is the side that needs to provide a two-factor token, and Server is the side that verifies the two-factor token.
In the Handshake Initiation packet, right after the timestamp field, we insert a variable length blob. In the Handshake Response packet, in the field named empty, we also insert the same blob. WireGuard already encrypts and HMACs these fields using ChaCha20Poly1305. But since the length of them changes, it not a backwards compatible change when the fields are non-empty.
The variable length blob is limited to 1024 bytes big, to prevent UDP packets from growing too large. It's a sequence of TLV elements:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+--+-+-+-+-+-+-+--+ | Type (8) | Size (8) | Payload (Size bytes) ... +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
We define the following extension types:
- 0x00 = Padding. Extra padding to hide the length of sensitive data.
- 0x01 = SessionIDAuth. Contains a 16-byte verification token so the Client can prove it knows the session ID.
- 0x02 = SetSessionID. Sent by the Server to set the 32-byte session ID in the Client.
- 0x03 = TokenRequest. Sent by the Server when it wants the Client to provide a two-factor token.
- 0x04 = TokenReply. Sent by the Client after the user entered the two-factor token.
SessionIDAuth contains the ephemeral pubkey from the handshake message hashed with the session ID as key
BLAKE2S(Epub, sessionID), this means that the Client can prove that it knows the session ID. If the Client doesn't provide a valid hash, the Server will refuse the login and instead send TokenRequest.
The TokenRequest contains a 32-byte symmetric ephemeral key. It also contains a byte saying what the most recent authentication failure reason was, and a flag saying what type of token we request, so the UI can display it nicely:
- 6-digit token
- 7-digit token
- 8-digit token
- text input
- password input
Unlike TokenRequest which is sent in the Handshake Response packet, the TokenReply is sent in the Handshake Initiation packet. The Handshake Initiation encryption uses only the server's long term public key. So to achieve better forward secrecy, we encrypt it an additional time using ChaCha20Poly1305 with the ephemeral key provided by TokenRequest.
TokenReplyPayload := AEAD(K, 0, TwoFactorToken, '')
How to use this
An experimental version of this is available in the totp branch of the TunSafe repo.
Enable TOTP authentication by adding this to the Server's config file:
[Peer] RequireToken = totp-sha1:ALFABETAGAMMATHETA,digits=6,period=30,precision=15
- digits: The number of digits, either 6, 7 or 8
- period: How often to refresh the token, by default every 30 seconds
- precision: Controls how old/new tokens to accept. 15 means all tokens from (TIME-15)/PERIOD to (TIME+15)/PERIOD will be accepted.
When connecting to this Server, the Client looks like:
After a valid token is entered, the client logs in as usual. If an incorrect token is entered 10 times, the account is locked. To unlock the account, enter a valid code, wait 30 seconds, enter the next valid code, wait 30 seconds, enter the next valid code. If there are any failed attempts in between, the unlock procedure aborts. For rate limiting, there's also a token bucket scheme that refills by one attempt every 30 seconds. In the future there may be a way to configure those settings.
This implementation is a TunSafe specific and experimental extension to the protocol. We would love to find a variant of this proposal, or another solution that can provide the same functionality across other WireGuard implementations. We think a standardized way of doing two-factor authentication would be hugely beneficial to the WireGuard community. We're available in
#tunsafe on Freenode to discuss this proposal further.