NAME

dkim2-milter - Standalone DKIM2 milter for Postfix

SYNOPSIS

# Verify only (no signing)
dkim2-milter --socket unix:/var/run/dkim2.sock

# Sign for a single domain
dkim2-milter --socket unix:/var/run/dkim2.sock \
    --domain example.com \
    --selector sel1 \
    --keyfile /etc/dkim2/sel1.pem

# Sign for any domain with keys in a directory
dkim2-milter --socket unix:/var/run/dkim2.sock \
    --keydir /etc/dkim2/keys

# With message snapshots for diff-based Message-Instance
dkim2-milter --socket unix:/var/run/dkim2.sock \
    --keydir /etc/dkim2/keys \
    --snapshot-dir /var/spool/dkim2/snapshots

# Testing with dns.json instead of real DNS
dkim2-milter --socket inet:8891@localhost \
    --keydir keys \
    --dns-json dns.json

DESCRIPTION

A standalone milter daemon that provides DKIM2 signature verification and signing for Postfix (or any milter-compatible MTA). Uses Sendmail::PMilter for the milter protocol.

Verification is enabled by default for all inbound messages.

Signing can be configured in two ways:

Single domain: --domain, --selector, and --keyfile. Signs only when the envelope sender matches the configured domain.
Key directory: --keydir PATH. The directory contains subdirectories named by domain, each containing key files named selector.key. The milter looks up the sender domain (with parent domain fallback) and uses the first key found. The algorithm is auto-detected from the key type (RSA or Ed25519).

When signing, a message carrying an X-DKIM2-Exploded header field (any value; Mailman and Sympa add X-DKIM2-Exploded: yes to every distribution copy) is signed with the exploded flag (-07 section 12.5), which a signature also gets whenever the message has more than one recipient; never at i=1. The field is removed from the message before it is signed and never leaves.

When the signing gate (Mail::DKIM2::Gate) refuses a message that carries an upstream DKIM2-Signature, the milter rejects it with 550 5.7.1 and the reason (-07 sections 13 and 13.2: a message with DKIM2 header fields that is not Validly Signed must not be sent on). A refused message with no signature goes on unsigned, the Message-Instances this host added removed. The same goes for a message the Signer refuses, such as one whose From is not Authentic for the signing domain (-07 section 11). New in 0.23; before, every refused message went on.

This program implements draft-ietf-dkim-dkim2-spec-07; see "STATUS" in Mail::DKIM2.

KEY DIRECTORY LAYOUT

The --keydir option points to a directory tree where each subdirectory is named after a domain and contains one or more PEM key files with a .key extension. The filename (minus the extension) becomes the DKIM selector.

/etc/dkim2/keys/
    example.com/
        sel1.key              # RSA key, selector "sel1"
        sel2.key              # another key (used if newer)
    sub.example.com/
        default.key           # selector "default"
    other.org/
        ed25519.key           # Ed25519 key, auto-detected

Domain lookup

When a message arrives from user@sub.example.com, the milter:

  1. Looks for a directory named sub.example.com/ under the keydir.

  2. If not found, strips the leftmost label and tries example.com/.

  3. Continues stripping labels until a match is found or no labels remain.

The signing domain (d= tag) is set to the directory name that matched, not necessarily the full sender domain.

Key selection

If a domain directory contains multiple .key files, the newest file by ctime is used. This makes key rotation straightforward:

# Generate a new key and publish its DNS record
openssl genrsa -out /etc/dkim2/keys/example.com/sel2.key 2048

# The milter will start using sel2 for new signatures immediately
# (after the domain lookup cache is refreshed on next restart).
# Keep sel1.key around until its DNS record has propagated.

Algorithm detection

The signing algorithm is auto-detected from each key file:

  • Ed25519 keys (32-byte raw or PEM-wrapped) use the ed25519 algorithm.

  • All other keys (RSA) use rsa-sha256.

No --algorithm flag is needed in keydir mode.

Caching

Domain-to-key mappings are cached in memory for the lifetime of the milter process. After adding or removing key files, restart the milter to pick up the changes.

OPTIONS

--socket, -s SPEC

Milter socket specification. Default: unix:/var/run/dkim2-milter.sock. Examples: unix:/path/to/sock, inet:8891@localhost.

--domain, -d DOMAIN

Signing domain (single-domain mode). Requires --selector and --keyfile.

--selector SELECTOR

DKIM selector (single-domain mode). Requires --domain and --keyfile.

--keyfile, -k PATH

Path to a PEM-encoded private key (single-domain mode).

--keydir PATH

Directory of per-domain key subdirectories (multi-domain mode). Cannot be combined with --domain/--selector/--keyfile.

--algorithm, -a ALG

Signing algorithm for single-domain mode. Default: rsa-sha256. In keydir mode, the algorithm is auto-detected from each key file.

--snapshot-dir PATH

Directory for message snapshots. Enables diff-based Message-Instance computation. On inbound, a message gets a snapshot only when the milter stamps its own m=1 or the top Message-Instance matches the content; the snapshot is stored under a random token, which is added to the message as X-DKIM2-Snapshot. On outbound, a top Message-Instance that no longer matches is explained by a diff against the snapshot that token names, and nothing else. Every mode removes any X-DKIM2-Snapshot a message arrives with (inbound then adds its own), so a token can neither come from outside nor leave the host. Both milters must share the directory.

Unsigned posts leave with one m=1

A message with no DKIM2-Signature (an unsigned post the inbound milter stamped m=1, perhaps a --wrap-single-part m=2 or a list manager's instance over it) is signed with one Message-Instance, m=1 (-07 section 12.1): the hashes of the copy sent and Recipes back to the post as it arrived, a From munge included. The instances it carried are removed, with X-DKIM2-Info: action=restarted-unsigned-mi=m=1,...;. The post as it arrived comes from those instances when the top one matches the content, else from the snapshot; a chain that does not undo cleanly gets no instance and the message is not signed. The From sent must still be Authentic for the signing domain (-07 section 11): without a From munge an outside author's post goes out unsigned. A post that arrived signed is extended with m=N+1 as before.

Not signing

Whenever the outbound milter does not sign (no key for the sender, a refused upstream chain, a signer failure), it adds no Message-Instance and removes every Message-Instance no DKIM2-Signature on the message covers (an m= above every signature's m=), with X-DKIM2-Info: action=removed-unsigned-mi=...;.

--allow-null-body-recipe

Sign a message with a Message-Instance that has a null body Recipe no upstream DKIM2-Signature covers (its m= is above every valid signature's m=; a signature with m=k covers instances 1 to k), whether it is the top instance or another unsigned instance was added over it: a null this host is introducing. Default: off, and such a message is delivered unsigned with X-DKIM2-Info: not-signed=null-body-recipe. When set, the message is signed provided the upstream signatures verify and the header history below the null Recipe checks out. For list hosts whose list manager rewrites bodies (content filtering, DMARC wrap) and adds an unsigned instance for the milter to sign.

A null that arrived already signed (a list host's post, forwarded unchanged) needs no option: the domain that declared the null signed it, and the milter extends the chain as for any other valid one. Whenever the milter signs over a null top, in either case, or over an unsigned null under the top (with the option), it adds the informational X-DKIM2-Info: action=null-body-recipe;.

--wrap-single-part

Inbound mode only, and needs --snapshot-dir. After verifying, stamping and keeping the arrival snapshot, wrap a message that is not multipart/* into a one-part multipart/mixed (its Content-* fields and body unchanged, see Mail::DKIM2::Wrap), record the wrap as a new unsigned Message-Instance, and keep the wrapped message as the snapshot the outbound milter diffs against. For stock Mailman, whose footer then becomes an extra MIME part instead of re-encoding the body. A message whose arrival snapshot could not be kept (an upstream instance that does not match) is not wrapped. Adds X-DKIM2-Info: action=wrap-single-part;.

--use-epilogue

Disabled in this release: the milter refuses to start with it. The Message-Instance is computed over the message rewritten with the epilogue while the original bytes are signed, so the mail fails verification.

When computing a diff-based MI, always store the previous message body in the MIME epilogue rather than as inline diff lines. This keeps MI headers small regardless of how much the body changed, at the cost of increasing the transmitted message size. Cannot be combined with --epilogue-threshold.

--epilogue-threshold N

Disabled in this release, as --use-epilogue.

When computing a diff-based MI, switch to epilogue storage only when the diff recipe would contain more than N literal (non-range) lines. Small changes (<= N lines) stay as a compact inline diff; large changes fall back to epilogue. 5 is a reasonable starting value. Cannot be combined with --use-epilogue.

--max-recipe-literals N

Without either epilogue option, a diff-based MI whose body recipe would need more than N literal lines (default 1000) gets a null body recipe instead: the previous body is declared unrecoverable rather than carried in the header. Cannot be combined with the epilogue options.

--dns-json PATH

Path to a JSON file with DNS key records (for testing without real DNS). Format: { "domain": { "selector._domainkey": [["txt", "v=DKIM1; ..."]] } }.

--verify, --no-verify

Enable or disable verification. Default: enabled.

--sign, --no-sign

Enable or disable signing. Default: enabled if signing config is available.

--help, -h

Show this help message.

POSTFIX CONFIGURATION

Add to main.cf:

# For inbound verification
smtpd_milters = unix:/var/run/dkim2-milter.sock

# For outbound signing (locally-generated mail)
non_smtpd_milters = unix:/var/run/dkim2-milter.sock

# Milter default action if the milter is unavailable
milter_default_action = accept

DEPENDENCIES

Requires Sendmail::PMilter 1.28 or later, a recommended (not required) dependency of the Mail-DKIM2 distribution:

cpanm Sendmail::PMilter

1.28 (July 2026) is the first release that answers a null-sender MAIL FROM:<>; with 1.27 the MTA waited out its milter timeout and bounces went out unsigned. This program refuses to start with an older version. deploy/smoke-null-sender-milter.pl in the interop repository checks a running milter answers a null sender.

AUTHOR

Bron Gondwana <brong@fastmailteam.com>

COPYRIGHT AND LICENSE

Copyright (c) 2025 Fastmail Pty Ltd. This is free software; you can redistribute it and/or modify it under the same terms as Perl itself.