Let’s say we want emails on our mail server to be encrypted at rest, such that only the user has the key. Luckily, there already exists a popular solution for encrypting emails such that only the recipient can read them: OpenPGP.

Using Dovecot Sieve scripts, we can easily PGP-encrypt all incoming email for a user.

A lot of people have done this before, and I didn’t come up with the idea. Please see the Further reading section for some recommended articles I referred to.

The only prerequisite is an existing Dovecot server set up and running. This guide will be 100% compatible with my mail server guide. This guide assumes you are using system users as mail users, and may require changes if you are using virtual users.

This instructions should be distro-agnostic, though it was written for an Alpine Linux server. I think the only Alpine-specific part should be how to install the required packages, which can just be replaced by the relevant command for your distro’s package manager.1

This is compatible with Dovecot’s mail_crypt plugin, because mail_crypt’s encryption is transparent to the user.

Finally, this only encrypts incoming mail, because Sieve scripts aren’t applied to outgoing mail.

Admin guide#

Install Pigeonhole (a Sieve implementation for Dovecot):

# apk add dovecot-pigeonhole-plugin

Set Dovecot to use Pigeonhole. Edit /etc/dovecot/conf.d/20-lmtp.conf:

protocol lmtp {
    mail_plugins = $mail_plugins sieve
}

If you use LDA, you should do:

protocol lda {
    mail_plugins = $mail_plugins sieve
}

Now set Dovecot to use the sieve_extprograms Sieve plugin. This allows Sieve to run external executables. Don’t worry; it won’t allow users to execute arbitrary executables, but only executables you specify.

sieve_extprograms may come installed with Pigeonhole, or you may have to install it separately. For me, my /etc/dovecot/conf.d/90-sieve.conf contains a comment that states:

The sieve_extprograms plugin is included in this release.

To enable sieve_extprograms, anywhere in your Dovecot config (I put it in /etc/dovecot/conf.d/90-sieve.conf):

plugin {
    sieve_plugins = sieve_extprograms
    sieve_extensions = +vnd.dovecot.filter
    sieve_filter_bin_dir = /etc/dovecot/sieve-filters
}

We add vnd.dovecot.filter to the list of Sieve extensions, to allow users to use the filter Sieve command. A filter is an executable that takes an email from stdin, performs an action on it, and outputs the modified email to stdout.

By specifying sieve_filter_bin_dir, we are saying that we will place any Sieve filters in /etc/dovecot/sieve-filters.

WARNING!

Users can execute any executable you place in /etc/dovecot/sieve-filters. Only put executables you trust in there!

Sieve filters will be executed with the following environment variables, and only the following environment variables:

  • HOME
  • USER
  • SENDER
  • RECIPIENT
  • ORIG_RECIPIENT

They can take one argument specified by the user in their Sieve script.

Now let’s add the Sieve filter itself. This can be any executable that takes an email from stdin and outputs the PGP-encrypted email from stdout.

Here’s one written in Perl. I had trouble with installing the Perl dependencies on Alpine, so I ended up using a Python script by Julian Andres Klode.

On Alpine Linux, to use this script, you should install python-gnupg as:

# apk add py3-gnupg

Now place the executable you want to use in /etc/dovecot/sieve-filters and make it executable (chmod +x). You can see the version of Julian Andres Klode’s script I use on my server here.

You also need to make sure that users can set their own personal Sieve scripts. You can set:

plugin {
    sieve = ~/.dovecot.sieve
}

to make it so that a user’s Sieve script would be at ~/.dovecot.sieve. You can also set

plugin {
    sieve = file:~/sieve;active=~/.dovecot.sieve
}

so that ~/sieve/ is a directory full of sieve scripts, and the active one is symlinked at ~/.dovecot.sieve. See Dovecot docs on Sieve script locations, or my further explanation of the sieve = file:~/sieve;active=~/.dovecot.sieve example on my previous blog post.

Restart Dovecot for your changes to take effect:

# rc-service dovecot restart

You are now done from the admin side of things.

User guide#

In order for gpgmymail (the script linked above) to have the user’s public PGP key, they need to import it to their system GnuPG keyring. If they have shell access,

user@localhost$ gpg --export --armor user@revsuine.xyz > public.asc
user@localhost$ scp public.asc user@revsuine.xyz:~/public.asc
user@localhost$ ssh user@revsuine.xyz
user@revsuine.xyz$ gpg --import ~/public.asc

Or you could copy and paste the ASCII armored public key into an SSH shell, etc.

DO NOT PUT YOUR PRIVATE KEY ON THE SERVER!

Not only is this a security hole, but this also entirely defeats the point of this setup, which is designed to protect against an attacker who gains full disk access to the mail server from reading your emails. If the private key is stored on the server, this attacker with full disk access will have the key to decrypt and read your emails.

Because $HOME and $USER are included in a Sieve filter’s environment, python-gnupg can see the public keys in the user’s personal GPG keyring.

If the user doesn’t have shell access, they need to send their public key to a server admin who can run gpg --import as their user (e.g. with doas -u).

You also need to mark the public key as trusted so that GPG doesn’t refuse to encrypt data with the key:

user@revsuine.xyz$ gpg --edit-key user@revsuine.xyz

Then enter trust, select 5, enter y, then enter save:

gpg> trust
  1 = I don't know or won't say
  2 = I do NOT trust
  3 = I trust marginally
  4 = I trust fully
  5 = I trust ultimately
  m = back to the main menu
Your decision? 5
Do you really want to set this key to ultimate trust? (y/N) y
gpg> save

Now the user needs to create or amend their Sieve script. A minimal Sieve script could be

require "vnd.dovecot.filter";

filter "gpgmymail" "user@revsuine.xyz";

Note that filter commands need to go before fileinto commands for them to take effect.

If your Sieve filter is named something else, replace gpgmymail with the name of your script (relative to /etc/dovecot/sieve-filters/).

Your Sieve filter does not need to implement behaviour such as “don’t encrypt emails from domain.com”, because this is exactly what Sieve scripting is for. If you want to apply conditions to encrypting mail, do it with Sieve, e.g.

require "vnd.dovecot.filter";

if not address :is :domain "from" ["revsuine.xyz", "gmail.com"] {
    filter "gpgmymail" "user@revsuine.xyz";
}

Assessment#

Given a trusted server admin to implement this, and not spy on emails prior to them passing through the Sieve filter, this solution protects against an attacker who can read the full disk of the server, as stated previously. Potential threats this defends against include seizure of the server by law enforcement who bypass full disk encryption, or a VPS host who reads the FDE key from RAM and reads the disk contents; essentially, any instance of a third party who gains full disk access.

This does not do much to protect against a server admin who is intent upon reading their users’ emails, because the email is unencrypted the whole time it moves through the Postfix queue. At the end of the day, there is really nothing at all that can be done to stop a server admin from reading something that arrives at the server unencrypted, such as an unencrypted email.

This solution is an improvement over services such as Protonmail or Tuta, because unlike with Protonmail, users of a mail server with this Sieve filter do not have to have their private keys stored on the server. Protonmail, assuming an entirely non-technical user, manages PGP keys for the user, and therefore generates and stores them server-side. However, with our solution, emails become encrypted on the server, but only get decrypted on the user’s local machine. Their public key is stored on the server, but not their private key. Also, unlike Protonmail and Tuta, this solution works out-of-the-box with IMAP or POP3, not requiring a bridge like Protonmail does.

As mentioned at the start, outgoing mail is still stored unencrypted on the server, unless the user has encrypted it themselves (e.g. with PGP).

This solution will also make it impossible to search for message contents, as they are all encrypted. If you also encrypt subject lines, you can essentially only search for emails by sender or date.

This solution shouldn’t break spam filters if they are integrated with your MTA, but if your spam filter happens after Sieve filtering for some reason (likely only if your spam filter is client-side), it obviously won’t work because the message contents are encrypted and unreadable to a spam filter. Modifying the email in this way also renders DKIM signing invalid, but DKIM validation should be integrated with your MTA, in which case you’ll still have an email header indicating DKIM status prior to encryption.

Compatibility#

OpenKeychain fails to decrypt emails encrypted this way. As far as I can tell, the only way these emails can be read on Android is by using Termux to decrypt emails with GnuPG, i.e. not with any conventional Android IMAP client.

Desktop Thunderbird seems to have an issue rendering quoted-printable or base64-encoded emails encrypted this way, however I’ve had no problem with GNOME Evolution. I haven’t tested with other desktop email clients.

Because all gpgmymail does is, essentially, encrypt emails with a Python wrapper for GnuPG, any desktop email client that uses GPG to decrypt PGP-encrypted emails should be able to read gpgmymail-encrypted emails. When you run gpg --decrypt on a gpgmymail’ed email, you will see the email headers twice, and then the email as it was prior to being gpgmymail’ed. You could easily not have a client render your email, and just read the raw decrypted email with gpg --decrypt.

Further reading#

In order:

  1. https://www.grepular.com/Automatically_Encrypting_all_Incoming_Email
  2. https://perot.me/encrypt-specific-incoming-emails-using-dovecot-and-sieve
  3. https://blog.jak-linux.org/2019/06/13/encrypted-email-storage/
  4. https://github.com/julian-klode/ansible.jak-linux.org/blob/dovecot/roles/mailserver/files/usr/local/lib/dovecot-sieve-filters/gpgmymail

  1. Tagged #alpine linux for findability when searching. ↩︎