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:

  1. Install OpenDKIM (apt-get install opendkim to do manually)
  2. Change the permissions on /etc/opendkim.conf before putting any passwords in (work-readable by default - chmod 400 /etc/opendkim.conf to do manually)
  3. In /etc/opendkim.conf:
    • Set Canonicalization to 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)
    • Set KeyTable to dsn:pgsql://vmail-dkim-reader:password@localhost/vmail/table=dkim_key_table?keycol=identifier?datacol=domain,selector,key
    • Set SigningTable to dsn:pgsql://vmail-dkim-reader:password@localhost/vmail/table=dkim_signing_table?keycol=pattern?datacol=identifier
    • Set Socket to local:/var/spool/postfix/opendkim/opendkim.sock so postfix will have access from its chroot
  4. 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)
  5. 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.)