This all started because GMail started blocking my mail server’s IP address. A close inspection of my mail logs showed no unusual activity, and certainly nothing spammy being sent from my system, but there is a strong recommendation to setup DKIM for email domains which I have not done yet (SPF has been in place for many years). This post documents setting up OpenDKIM in my existing virtual mail infrastructure.
For reference, this is the error from the bounce message my MTA produced:
Remote-MTA: dns; gmail-smtp-in.l.google.com Diagnostic-Code: smtp; 550-5.7.1 [IP_ADDRESS_REDACTED 12] Our system has detected that this message is 550-5.7.1 likely unsolicited mail. To reduce the amount of spam sent to Gmail, 550-5.7.1 this message has been blocked. Please visit 550-5.7.1 https://support.google.com/mail/?p=UnsolicitedMessageError 550 5.7.1 for more information. IDENTIFIER_REDACTED - gsmtp
(As an aside, I registered for Google’s Postmaster tools which involved adding each domain then placing a TXT record to verify in the domain’s root DNS. After doing all that, all it managed to tell me was my server does not have sufficient traffic to display any reputation information.)
Setting up the database
My mailserver is setup around a virtual mail database, which is used as both data source for the MTA and spam filtering and the location of authoritative information. All of the other data for running the mailserver, with the exception of the emails themselves, are in the database and I already have a tool,
mailadm, that I have written for managing the mail system through this database so I have decided to store the DKIM keys there. Most information online promotes on-disk key storage but I am reluctant to create an exception to the current single authoritative source and repository for all virtual-mail data setup.
The first thing I did was to create a new user who will be able to read the DKIM keys, this way it is treated the same way as the spam filtering (which has its own user and can only access the tables/views it needs) and the MTA (which uses a different user and can only access the tables/views it needs to route mail). With my current permissions setup this has to be done as the database super-user, so cannot be done with my
mailadm tool (in reality, this is handled by SaltStack on my system):
CREATE USER "vmail-dkim-reader" WITH PASSWORD 'password';
N.B. ensure you do not use any of the delimiter characters (
:/@+?=) in the password, or encode them if you do. See the
opendkim man page for mode details.
Then I added a new table to my email management database (in reality this is done by my
mailadm tool, taking the database schema to version 8):
CREATE TABLE dkim ( id SERIAL PRIMARY KEY, domain_id INTEGER NOT NULL REFERENCES domains(id), selector TEXT NOT NULL DEFAULT 'default', key TEXT NOT NULL, UNIQUE(domain_id, selector) ); CREATE VIEW dkim_signing_table AS SELECT selector || '.' || domains.domain AS identifier, domains.domain AS pattern FROM dkim INNER JOIN domains ON dkim.domain_id = domains.id ; CREATE VIEW dkim_key_table AS SELECT selector || '.' || domains.domain AS identifier, domains.domain AS domain, selector, key FROM dkim INNER JOIN domains ON dkim.domain_id = domains.id ; GRANT SELECT on dkim_signing_table TO "vmail-dkim-reader"; GRANT SELECT on dkim_key_table TO "vmail-dkim-reader";
To support more patterns than just host (as in “user@host”, in my case host is my domain - see
opendkim.conf’s man page) the
dkim_signing_table view will need another join to
domains for the implicit domain patterns (retaining the
INNER JOIN for the identifier) and probably another linking table for custom keys per-user (or other specific patterns). Although at this stage separating the signing table and key table views is redundant I have done it for a degree of future-proofing.
mailadm to provide the necessary commands to add/remove DKIM information for existing domain. As a future enhancement, I may auto-generate DKIM keys for domains on addition to the database (unless suppressed with a
--no-dkim option) - I added an issue for this idea to my Git issue tracker.
I am not going replicate all of the code changes here - some of it is boiler-plate database interface and the code to add the new table as a database update.
The interesting bit is the new dependency on cryptography, the code to generate a new key and display the public key (both in plain and ready for DNS formats):
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa #... def generate(session, domain, selector='default'): """ Generate a new Key. args: session: A valid database session domain: domain to generate the key for selector: selector to generate the key for (defaults to 'default') """ if dkim_exists(session, domain, selector): logger.warn("Unable to generate DKIM key for %s.%s - already exists.", selector, domain) else: private_key = rsa.generate_private_key( backend = default_backend(), public_exponent=65537, key_size=2048, ) logger.debug("Generated new RSA key.") with db_updater(session): session.add(DKIM( domain=find_domain(session, domain), selector=selector, key=private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption() ).decode('utf-8') )) def get_public_key(session, domain, selector='default'): """ Returns the public key in base64 encoded DER format. args: session: A valid database session domain: domain to generate the key for selector: selector to generate the key for (defaults to 'default') """ logger.debug("Getting public key for domain: %s.%s", selector, domain) db_dkim = find_by_domain_selector(session, domain, selector) private_key = serialization.load_pem_private_key(db_dkim.key.encode('utf-8'), backend=default_backend(), password=None) return base64.b64encode(private_key.public_key().public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo # I think this is ignored for DER encoding ))
Once keys are generated, they need to be added to the email domain’s DNS. I tweaked
mailadm to be able to output the appropriate stanzas. The general format is a TXT field called
selector._domainkey in the domain (so the full record name is
selector._domainkey.your-domain.tld). The content of the field is
v=DKIM1;k=rsa;t=s;p=PUBLIC_KEY (see RFC6376 for details -
t=s specifies that the key cannot be used for subdomains,
PUBLIC_KEY is the base64-encoded DER public key).
Configuring postgresql for OpenDKIM
Before configuring OpenDKIM, I need to configure postgres to allow it to authenticate through the unix socket as the dkim reader user. Within my infrastructure, SaltStack takes care of this configuration.
The new configuration lines that are required are a new entry in
pg_hba.conf to specify the map that should be used to map unix accounts to the
vmail-dkim-reader user for the
local vmail vmail-dkim-reader peer map=maildkim
And the corresponding map in
pg_ident.conf to map the opendkim user to that:
maildkim opendkim "vmail-dkim-reader"
Setting up OpenDKIM
Again, SaltStack does all of this for me but the configuration changes it makes are:
- Install OpenDKIM (
apt-get install opendkimto do manually)
- Change the permissions on
/etc/opendkim.confbefore putting any passwords in (work-readable by default -
chmod 400 /etc/opendkim.confto do manually)
relaxed/relaxed, which makes it less strict on certain modifications (which Microsoft’s products like to do to both headers and body - see this article for more information)
local:/var/spool/postfix/opendkim/opendkim.sockso postfix will have access from its chroot
- Create the directory with appropriate permissions in postfix’s chroot (
mkdir /var/spool/postfix/opendkim; chown opendkim:postfix /var/spool/postfix/opendkim; chmod 640 /var/spool/postfix/opendkim)
- Restart opendkim (
systemctl restart opendkimto do manually) for changes to take effect (
reloaddoes not seem to be sufficient when I tested.)
Setting up Postfix
The postfix user needs adding to the opendkim group (
gpasswd -A postfix opendkim if not doing with configuration management) then, finally, some extra configuration option needs adding to postfix to tell is to use opendkim:
smtpd_milters = unix:/opendkim/opendkim.sock non_smtpd_milters = $smtpd_milters # To avoid losing any mail, accept if the milter fails milter_default_action = accept # Send bounce emails to the milter to be signed internal_mail_filter_classes = bounce
After all of that is done, restart postfix and we can test it…
The setup can be tested by sending a blank email to
email@example.com and with the mail tester service.
Setting up DMARC
Once DKIM is working correctly (I am now getting 10/10 on with the mail tester), I added a DMARC DNS entry (a TXT record called
_dmarc) to each of my domains to specify that all email from my domain must pass SPF and DKIM checks. For now I set the policy to quarantine, as I want to get aggressive but also see it run without problems for a few weeks before setting it to
(I setup a special alias to receive the reports.)