Photo by Anna Tarazevich: https://www.pexels.com/photo/close-up-photo-of-a-stamp-on-a-paper-5425649/

Let’s do Postfix slowly and properly – Part 12: DKIM

Do Kindly Internalize Mail (from me)?

DKIM or DomainKeys Identified Mail is increasingly a must, even for selfhosted email. In this post in the Postfix series I will expand rspamd‘s role to include DKIM signing and checking.

I finished off the last post in this series some three and a half years ago, by noting that I would need to return to “address issues like DKIM and DMARC”. And boy, have they become issues in the meantime.

Gmail now requires senders to have a DMARC policy (at least if you send out a lot email but who knows where they draw the line?). And while you can have a DMARC without referencing DKIM (only using SPF checks), the feeling I’m getting is that that would be a shortsighted solution that might not work in practice for much longer. So we might as well do DKIM and DMARC and in proper and short order.

Back in 2020 I tried to recap all the various certs by saying that

Where DNS/PTR is about identity (are you who you say you are?), MX/SPF is about permissions (are you allowed to do what you’re trying to do?)

“Quick recap: DNS, MX, PTR and SPF” from “Let’s do Postfix slowly and properly – Part 8: PTR and SPF records”

Seems like we’ve got everything, right? So what new angle does DKIM cover? Well, to be honest, I don’t think it really does add a whole new angle. DKIM corroborates the story told by SPF, i.e. that the email that is being delivered originates from its purported and legitimate source. SPF works by saying what hosts are allowed to send email on behalf of your domain; DKIM works by essentially using a signet ring to put your seal in the wax of outgoing email – thus proving the origin of the email (and that it hasn’t been tampered with). Only, the signet ring and seal and wax are all cryptographic, of course.

Why DKIM?

Wikipedia says “DKIM and SPF provide different measures of email authenticity” but doesn’t go into details about how they supposedly complement each other. For the longest time I couldn’t see what DKIM contributed to the authenticity of me as a sender when I already had SPF going. An attacker can cheat SPF by gaining control of either domain registration or server. But the same mostly goes for DKIM.

However, setting up DKIM, not on my own server, but on a shared host, I realised something. Runbox provides email for my other domain. When I set SPF for that domain, all I am saying is that email from my domain must originate from Runbox SMTP servers…

[ ~ ] host -t TXT madsmi.de
madsmi.de descriptive text "v=spf1 include:spf.runbox.com -all"
[ ~ ] host -t TXT spf.runbox.com
spf.runbox.com descriptive text "v=spf1 ip4:91.220.196.211 ip4:91.220.196.212 ip4:91.220.196.225 ip4:185.226.149.25 ip4:185.226.149.26 ip4:185.226.149.37 ip4:185.226.149.38 ip6:2a0c:5a00:149::25 ip6:2a0c:5a00:149::26 -all"

But that criterion is satisfied by every single Runbox customer! Of course, Runbox doesn’t allow you to send on behalf of another user’s domain but I can see how relying on the proper housekeeping of an email provider is not the guarantee that it should be. So: Should one of my Runbox neighbours find a hole in the walls and send for my domain (but not on my account, using my keys?), DKIM would be a good extra line of defense against impersonation. Does it add much to a selfhosted setup? I remain unconvinced but Google and Microsoft aren’t so there’s not much point in arguing.

What about incoming email? DKIM help us guard against scammers and phishers who pretend to be someone or something we trust but aren’t. Bear in mind, though, that it doesn’t help against lookalike domains.

How does DKIM work?

You have a private cryptographic key that “signs” your outgoing email. The signature is an added header to the email that looks like this:

DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=brokkr.net; s=myselector; t=1715449764; h=from:from:reply-to:subject:subject:date:date:message-id:message-id:	 to:to:cc:mime-version:mime-version:content-type:content-type;
bh=tvWZqA8moheB+9atpeT8kJowlowHd0kugctmT2VsJoE=;b=QWUwcJxxsZYq1cYRMF48Qn7IfQGduI4jzOeQYkoH+fji4O5bF7gB8QDxtwjF4Tu6ZN945UPzjw1s55mZMTTXwyQB1D5UD3084df7eF4uhYz8oLTk3tkezyZUMSFZ...

The signature is based on the private key as well as a hash of the contents of the email (selected headers and email body, see the ones listed after h=). From the private key you can derive a public key. The public key can be used to verify that an email was signed by the private key but cannot itself be used to sign emails. Thus it is safe to make it publically available so that anybody – including other people’s SMTP servers – can get it and use it to check if email purporting to be from you is genuinely from you. If the email has been signed with any key but your private key, the check will fail. Because of the inclusion of the email contents hash in the signature, the check would also fail if the contents have been altered.

For now, all we need to know of DMARC – which I will devote the next post to – is that it’s a public announcement about your domain’s policies regarding failed SPF and DKIM checks. This can happen for a number of reasons that are not the sender’s fault, most notably if the email’s contents have been changed after it was sent. (Inability to fetch the DKIM DNS record may also be a concern if connection is spotty).

I likened the DKIM signature to a wax seal on a letter. That is a decent analogy but not perfect. Like a seal the signature implies the authenticity of the sender and goes some way towards preventing others from altering the message in transit in a way that is undiscoverable to the recipient. Unlike the seal which requires you to break it to read the contents, a DKIM signature is not a defense against others reading the email in transit. Though cryptographic techniques are employed in producing the signature, DKIM is not a means of encrypting the email. For that you need relay encryption (for encryption in transit) or things like GPG/PGP (for encryption at rest). Also remember that DKIM signing, despite the personal sound of it, is domain level, not individual level. So the entire court (domain) shares one seal.

As is apparent DKIM’s efficacy relies entirely on the recipient. No matter how thorough I have been in setting up DKIM on my outgoing email it will not make a blind bit of difference if the recipient disregards it. If the recipient neglects any of the following upon receipt of a signed email, the point will be moot: a) lookup the public key, b) check the signature against the key, c) follow the proscriptions set down in the DMARC policy (e.g. reject if DKIM check fails).

Implementing DKIM

The main focus and challenge for a selfhosted emailer is getting your outgoing mail DKIM-signed. For this you will need three things: a private key to sign with, registering the public key as yours (so recipients can check incoming mail) and some alterations to your outgoing mail flow to ensure that you are actually signing emails.

I wrote about why I opted for rspamd over spamassassin back in 2020 and that choice is now proving fortuitous. You see, rspamd not only comes with a DKIM checking feature but also a DKIM signing module. Where most guides will point you towards OpenDKIM (which I certainly didn’t try and utterly fail to get off the ground) I will go with an approach based on ‘the principle of the nail at hand’ (as the saying goes in my native tongue, meaning making a virtue of using whatever you happen to have lying about).

A signature

For a basic understanding of how rspamd works in general and as a milter (mail filter) in a Postfix setup specifically, I will direct you to my post on rspamd. That one is concerned with filtering incoming mail, though, and here we need to use it on outgoing mail. Basically, at some point during submission Postfix will call on rspamd, hand over the email and receive it back from rspamd, hopefully with a signature attached. If for whatever reason you don’t use rspamd already, it should be available in most repos as just rspamd. Install and proceed.

First we need to create a key pair. The included utility rspamadm can help us out with that:

rspamadm dkim_keygen -s 'myselector' -b 2048 -d mydomain.tld -k mydomain_tld.private > mydomain_tld.txt

The domain (-d) should match the domain in the email’s From header. Bit key size (-b) is the length of the private key (2048 being what the rspamd authors sugget). Key file name (-k) is simple the file name that private key will be given (in the current, working directory). The output (a DNS text record which contains the public key) is redirected to a .txt file. The filenames can be chosen arbitrarily, as long as the private key is correctly referenced later on.

What about the ‘selector’ (-s)? The selector is the name of a subdomain to your domain. This is the subdomain that will be queried for the DNS record (and thus your public key). This might sound like it will require a lot of extra work with setting services listening and reverse proxies proxying. Fortunately, that is not the case. The query only goes to the domain registrar (or wherever you have your DNS records), no further. The subdomain only exists to serve this one TXT record. It does not have an A or CNAME record and so it never causes traffic to hit your server. I will go into detail about setting up the TXT record in the next section.

So that the files end up belonging to rspamd (and to test that the current directory is accesible for the _rspamd user) the command should be run as the _rspamd user, so preface with sudo -u _rspamd (or exec in to a docker container as _rspamd or whatever other ways there are to accomplish the same). Note the underscore at the start of the name. At the end you should have something like

-rw------- 1 _rspamd _rspamd 1.7K May 11 17:08 brokkr_net.private
-rw-r--r-- 1 _rspamd _rspamd 457 May 11 17:08 brokkr_net.txt

I probably wasted an hour or two to permissions issues so I’d advise you to create a keys directory as root and chown it to _rspamd. Then as _rspamd a) cd to that directory b) do as described above and c) check to see if permissions match the example (600 and 644, for private and public keys respectively). Needless to say, if you’re running rspamd in a container, the keys directory needs to be on a volume. Alternatively, they can be put into a Redis database but I couldn’t be bothered.

DNS TXT records

How do you make your public key, well, public? As mentioned it goes in to a DNS TXT record that can be accessed by asking whoever does your DNS for you. For me, as for most selfhosters I assume, that’s the domain registrar, in my case Namecheap. Whoever you use, they will likely have a DNS record maintenance section where you can add and edit DNS records, mostly As and CNAMEs. TXT records are essentially the ‘assorted’ section of the DNS warehouse. You can add as many as you like, invent your own, etc. Apart from my SPF and DKIM records, I have a Google Verification record. It’s just an arbitrary text string, usually a series of variables, that can be got by querying your domain (or subdomain). Here’s what you should get when it works:

[ ~ ] host -t TXT myselector._domainkey.brokkr.net
myselector._domainkey.brokkr.net descriptive text "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs0yNaUD03YmSWWsVZBYIGB6hINpbZeL3CETHZx054SQtI1Ki5GcAwGbeZX+CWx5hr34sDTqdRIymDG5q0G2N5gTmoxmmS98dXFQGQirACB2y2Vg6E1KerOIHYx8UZeRZe/nGOgzdFKom8wDn0RXA5zVpMkxNfSB9f2lZU5BSOHRY0t+gvw5f7n3Fy0qds+8kx" "Sm46Nx2gvw418rttf4wT75iacvGk43SvD7EdZG5zTn7TQXcQ7BdXwAE3maiF1oNnA6a1lMVZwTH5OG/Clcm6KJoHegQsgxtN8516otWk9DyvNiMzpjfGWrpbryD81lMjUfXGhCNS1xY3UpTwLWQ3QIDAQAB"

Depending on your registrar, maybe you can upload the record in the txt file as is, maybe not. If your arrangement is more GUI style, as is mine, you will need to extract the following and enter it into text fields in said GUI:

  • A type (of record). I pick TXT but maybe some registrars have special formats for DKIM entries, who knows.
  • A subdomain or host. This is the first part of the .txt file. It consists of the name of your selector (here: myselector) and ._domainkey. So for me the fully qualified domain name for the subdomain is myselector._domainkey.brokkr.net. Namecheap only asks me to fill in the subdomain part (myselector._domainkey) as host, though.
  • The record itself. As I mentioned this is a series of variables, beginning with v=DKIM1 and ending with p=MIIBIjANBgkqhkiG9w0BAQE…. Semi-colons separate variables. Quotation marks are used, I think, to tie multiple lines into one long string. I had to delete them as well as line endings before copy-pasting (so the p= value is one long, unbroken line of characters; no semi-colons, no quotation marks, no spaces, no line endings).
  • TTL or time to live. How long should a server consider the resulting record valid before asking the DNS server again? I just leave it as ‘Automatic’ and figure Namecheap knows best.

Allow for some time after saving for the record to spread. You can check if it has ‘taken’ by using the host command above and whether it is valid by using a web service like mxtoolbox.

Configuring rspamd

[Note: This simple config builds on a working rspamd copy, including setting it up with a database. If you don’t have previous experience with the program, I would recommend reading the blog post on it, even if you do not intend to use it for actual spam filtering, but only DKIM signing] The DKIM signing module in rspamd is off by default. It is turned on by adding a config file called dkim_signing.conf to either the local.d or the override.d directories with the setting enabled set to true. Copy the .conf file from the modules.d directory to the local.d directory and edit settings in the copy. Enable the module:

# local.d/dkim_signing.conf                                                                                                                                    
enabled = true; 

… and customise the domain specific settings:

# Domain specific settings                                                                                                                                     
domain {                                                                                                                                                       
                                                                                                                                                               
  # Domain name is used as key                                                                                                                                 
  mydomain.tld {                                                                                                                                                 
                                                                                                                                                               
    # Private key path                                                                                                                                         
    path = "/volumes/rspamd/keys/mydomain.tld.private";                                                                                                          
                                                                                                                                                               
    # Selector                                                                                                                                                 
    selector = "myselector";                                                                                                                                   
                                                                                                                                                               
  } 

Basically, just insert the name of your domain and make sure the path to the private key matches what you chose earlier. This of course assumes only one domain and one key. If you need more, consult the documentation.

If you’ve read up on various DKIM settings, it’s worth noting that Rspamd comes with some defaults that seem not to invite argument:

Rspamd always uses relaxed/relaxed encoding with the rsa-sha256 signature algorithm, which is deemed to be the most suitable option for all cases.

https://rspamd.com/doc/modules/dkim_signing.html#dkim-key-management

The short version is that the relaxed/relaxed setting refers to “canonicalization”. Canonicalization has to do with how lenient we want to be towards changes in the header/body of the email by various servers that the email passes through. Some of them may take umbrage with various aspects of your email formatting, like tabs vs. spaces, and make changes. Now, remember that signing the email uses a hash of the body and select headers and you can probably imagine that this can easily create problems at the signature check stage. Essentially relaxed means the email can still pass the test even if minor stylistic changes like these have happened. ‘Simple’ on the other hand will cause the same test to fail (assuming changes). I don’t have an issue with Rspamd’s seemingly hardcoded default so this is just something to take note of.

You should now (re)start rspamd. Check the logs carefully for any sign (no pun intended) that the module is not working.

Configuring Postfix

Though spam filtering and DKIM signing may sound like very different things, to Postfix they’re both just external processes that work on mail. These are standardised as ‘milters’ and as I noted last time, that makes them fairly easy to work into Postfix’s flow. The main difference between the two jobs is what mail flow, incoming or outgoing, they need to squeeze themselves into. Postfix has two settings, one for each, and the settings are essentially just sequential lists of milters that want access to mail in that particular flow. Milters working on incoming mail are listed in smtpd_milters and outgoing in non_smtpd_milters. Note the ‘d’ in smtpd. The nomenclature is similar to how Postfix labels talking to other MTAs, when taking on a server role, smtpd (d for daemon) and smtp, when talking to other MTAs but in a client role.

Because we are using rspamd for both purposes, the setup here is surprisingly simple:

smtpd_milters = inet:local_ip_address:11332
non_smtpd_milters = inet:local_ip_address:11332

I have written “local_ip_address” rather than just inserting “localhost” because in my setup rspamd runs in a separate container with a seperate ip address on my docker mail network. This works just fine but it is worth noting that it works because of default setting in rspamd, namely the one that lists local_addr:

    local_addrs [        
        "192.168.0.0/16",        
        "10.0.0.0/8",                                                          
        "172.16.0.0/12",   
        "fd00::/8",
        "169.254.0.0/16",                                                      
        "fe80::/10",                                                           
        "127.2.4.7",              
    ]       

By default, to access rspamd as a milter, one of several conditions must be met, one of them being that the process talking to the milter is on a local network. A local network is defined as one of the ip ranges above. Since they’re pretty bog standard, it’s hard to see that fail, but it’s still worth being aware of. You can see local_addrs and the resulting configuration in general by issuing the command rspamadm configdump.

Finally, if you have other non_smtpd_milters, you should pay close attention to the order in which they are listed. As mentioned above, the DKIM signature is made using the private key but the resulting string of characters is also dependent on a number of email headers and the email body. This is what makes DKIM useful as an anti-tamper measure. Change the headers or the body and the signature will fail the check. Thus any milter that might change the contents should be inserted before rspamd.

Testing

The first port of call for testing the implementation should obviously be whether a DKIM signature can be detected on the receiving end (see also the Impact section below). So mail to other email accounts under your control and check the source of the email to see if you can find any trace of your DKIM-Signature:. If that’s not the case, check your Postfix and Rspamd logs.

Sadly, I’ve lost my logs from the actual period when I was implementing DKIM. What I mostly remember is how unhelpful they were. Start by checking the Rspamd logs. If it cannot process the private key for whatever reason, it will let you know on startup. As for each individual signing, I believe you’re better off asking Postfix. Set Postfix to a reasonably high level of verbosity (run the binary with -v or -vv), consider disabling the smtpd_filters setting (the incoming spam filtering) to reduce noise and filter the logs with grep milter. If you get no hits, Postfix is a likely culprit. In other words: Postfix should be saying something about at least trying to use Rspamd as an outgoing milter.

Finally, the best ressource for testing DMARC on the internets is the interactive learndmarc.com site. It asks you to send it an email from the setup you’re testing and then walks you through it’s checks one by one, rather than overwhelm you with all the stats, all at once. And even if you have still not implemented DMARC, it will conveniently check whether you would pass the DMARC test, supposing you had had a hardline DMARC policy in place (neatly marked ‘simulated DMARC’).

Impact

I recommend using Gmail… as a recipient for testing your new DKIM setup. No seriously, while Google won’t tell you in numbers how much less of a spammer they consider you, now that you have DKIM working (fingers crossed), they do make it supremely easy to see if it is indeed working. Check these before and after shots:

Before… Note that I was still able to push articles on to Gmail in March of 2024 without DKIM, only buoyed by SPF.
After. Note that I do not at current have a DMARC policy in place.

Of course, the thing about Google is they won’t tell you what their internals say about your trustworthinesss. Because then somebody would of course game that #WhyWeCant HaveNiceThings. So for that let’s use my paid email supplier Runbox who don’t mind displaying the assigned SpamAsssassin score in the email itself.

Before:

X-Spam-Checker-Version: SpamAssassin 4.0.0 (2022-12-13) on antispamc05.runbox
X-Spam-Level: 
X-Spam-Status: No, score=0.0 required=5.0 tests=HTML_MESSAGE,KAM_DMARC_STATUS,
	SPF_PASS,T_SCC_BODY_TEXT_LINE autolearn=disabled version=4.0.0
X-Spam-Report: 
	* -0.0 SPF_PASS SPF: sender matches SPF record
	*  0.0 KAM_DMARC_STATUS Test Rule for DKIM or SPF Failure with Strict
	*      Alignment
	*  0.0 HTML_MESSAGE BODY: HTML included in message
	* -0.0 T_SCC_BODY_TEXT_LINE No description available.

… and after:

X-Spam-Checker-Version: SpamAssassin 4.0.0 (2022-12-13) on antispamc02.runbox
X-Spam-Level: 
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
	DKIM_VALID_AU,HTML_MESSAGE,SPF_PASS,T_SCC_BODY_TEXT_LINE
	autolearn=disabled version=4.0.0
X-Spam-Report: 
	* -0.0 SPF_PASS SPF: sender matches SPF record
	* -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature
	*  0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily
	*      valid
	* -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's
	*       domain
	*  0.0 HTML_MESSAGE BODY: HTML included in message
	* -0.0 T_SCC_BODY_TEXT_LINE No description available.

So as is apparent, I’ve managed a whopping shift in SpamAssassin scores from 0 to -0.1 out of 5. Clearly a passing DKIM signature does not count for much in terms of proving that you’re not a spammer. Which is an excellent segue to a reminder that DKIM is not really about anti-spam. It’s about anti-phishing.

Spammers in my experience are lazy and most don’t bother with things like DKIM (even if they could without much hassle). So with that in mind why don’t we use that fact and penalise non-DKIM users with a bad spam score? I’m guessing here but probably because they’re already being penalised for not doing SPF or even doing basic SMTP correctly. Real people also don’t do DKIM, so you get a clearer distinction by focusing on whether the basics are in order. Obviously, this will change over time. In two years, maybe, the lack of DKIM will be severely punished.

Failed DKIM, however, very much tells you whether someone is trying to pass themselves off as someone they’re not (a cornerstone of phishing). This is a separate cause for concern from whether they’re trying to sell you something. I suspect that Google treats it as such whether they tell me or not.

I will not go into details about the whole question of ‘alignment’ in this post (stay tuned). Just keep in mind that alignment in general is about checking whether your story, the whole story you’re telling, holds up when viewed in total. Also DKIM alignment is one thing, SPF alignment another, and DMARC alignment a third that ties the former two together.

SPF and DKIM together does a lot for your credibility, however, with these two in place we have done so much that it does not make sense not to push the last piece of the puzzle into place. So in the next post, oming up shortly, we will take care of DMARC.

Close-up Photo of a Stamp on a Paper © Anna Tarazevich, Pexels license

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.