Setting up DKIM and DMARC
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 SigningTable
in 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.
I modified 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
))
DNS
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 vmail
database:
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 opendkim
to do manually) - Change the permissions on
/etc/opendkim.conf
before putting any passwords in (work-readable by default -chmod 400 /etc/opendkim.conf
to do manually) - In
/etc/opendkim.conf
:- Set
Canonicalization
torelaxed/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) - Set
KeyTable
todsn:pgsql://vmail-dkim-reader:password@localhost/vmail/table=dkim_key_table?keycol=identifier?datacol=domain,selector,key
- Set
SigningTable
todsn:pgsql://vmail-dkim-reader:password@localhost/vmail/table=dkim_signing_table?keycol=pattern?datacol=identifier
- Set
Socket
tolocal:/var/spool/postfix/opendkim/opendkim.sock
so postfix will have access from its chroot
- Set
- 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 opendkim
to do manually) for changes to take effect (reload
does 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…
Testing
The setup can be tested by sending a blank email to check-auth@verifier.port25.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 reject
:
v=DMARC1;p=quarantine;rua=mailto:postmaster@my_domain.tld
(I setup a special alias to receive the reports.)