NAME
Mail::Milter::Authentication::Handler::DKIM2Sign - Handler class for DKIM2 signing
DESCRIPTION
Signs outbound email with DKIM2-Signature headers and adds Message-Instance headers for Chain of Custody tracking. Runs in the addheader_callback phase so the signature covers all headers including those added by other handlers.
Signing keys can be configured statically per domain, or looked up dynamically via an HTTP REST endpoint.
Before it signs, the handler asks Mail::DKIM2::Gate (the decision bin/dkim2-milter and bin/dkim2sign make) about the message exactly as it would sign it, its own new Message-Instance included: any upstream DKIM2-Signatures must verify (a Message-Instance above the top signature, the one being signed, is allowed), the Message-Instance chain must match the content and undo cleanly, a top signature with nd= must name the signing domain, and an unsigned Message-Instance with a null body Recipe (a null this hop introduces, typically a list manager's) is signed only with allow_null_body_recipe. A signature with m=k covers instances 1 to k, so this applies to the top instance and equally to one under the handler's own new instance. A null that arrived signed is extended. A message with no upstream chain is signed as it always was.
When the handler recomputes its Message-Instance from a snapshot and drops broken intermediate Message-Instances on the way, it deletes those fields from the message once it has signed, through the framework's change_header() (an empty value: SMFIR_CHGHEADER in milter mode).
On a refusal nothing is signed and no Message-Instance is added. A refused message that carries an upstream DKIM2-Signature is rejected with reject_mail("550 5.7.1 not signing: <reason>") (-07 sections 13 and 13.2: it must not be sent on; new in 0.23). Otherwise every Message-Instance no DKIM2-Signature covers (an m= above every signature's m=) is removed, with X-DKIM2-Info: ... action=removed-unsigned-mi=...;, as also when the domain has no signing configuration or the Signer fails. The reason is logged (dbgout, LOG_INFO) and counted in dkim2_sign_total (result upstream-chain, broken-mi-chain or null-body-recipe), and for the last two, as with dkim2-milter, an X-DKIM2-Info: ... action=not-signed=<reason>; field is prepended. When it signs over a null body Recipe (a null top, or an unsigned null under the top) it prepends X-DKIM2-Info: ... action=null-body-recipe;. If the Signer itself refuses (result fail, e.g. over a DKIM2-Signature it cannot key, which the gate has already refused), nothing is added and the failure is logged as an error. That includes a From that is not Authentic for the signing domain when this hop originates the message or a Message-Instance it signs changed From (-07 section 11): the message then goes on unsigned, or, when it carries an upstream DKIM2-Signature, is rejected 550 5.7.1 as above.
This module implements draft-ietf-dkim-dkim2-spec-07; see "STATUS" in Mail::DKIM2 for what that means for the wire format and the API, and "CONVENTIONS" in Mail::DKIM2 for the option, input and error conventions every module here follows.
LIMITATIONS
Bcc recipients are recorded in a single rt= (Bcc leak)
The milter signs each message once, recording all of the SMTP transaction's envelope recipients (every RCPT TO seen in envrcpt_callback) in a single DKIM2-Signature rt= tag. It does not split the message into per-recipient instances.
This is fine for a forwarding hop, where the message has already been split at origination and each copy carries a disclosed recipient set. But at origination / submission, a message with undisclosed (Bcc) recipients — envelope recipients that do not appear in the To:/Cc: headers — will have those Bcc addresses recorded in rt=, visible to every recipient. That leaks the Bcc, contrary to draft-ietf-dkim-dkim2-spec-07, whose rt= description requires that Bcc recipients not be revealed to other recipients.
The milter cannot fix this itself: the Postfix milter protocol modifies a single queued message at end-of-message and cannot fan one message out into several separately-signed instances. Bcc-safe origination must split the message into one instance per recipient (or per disclosed group) before DKIM2 signing — in the submitting client/MSA, or via a Postfix content filter that re-injects per-recipient copies through the signing milter. See deploy/SERVER.md ("Bcc-safe origination: splitting recipients") for a content-filter recipe. A native MTA that emits per-recipient instances directly does not have this problem; it is specific to the bolt-on-milter model.
CONFIGURATION
"DKIM2Sign" : {
"domains" : { | Static domain configs
"example.com" : { |
"selector" : "sel1", | DKIM2 selector
"keyfile" : "/path/to/key.pem" | Private key file path
}, |
"other.com" : { |
"selector" : "default", |
"key" : "base64_pem_data" | Or inline key data
} |
}, |
"key_endpoint" : null, | HTTP endpoint for dynamic key lookup
| GET {url}?domain=X
| Response: {"selector":"s1","keyfile":"/path"}
| or: {"selector":"s1","key":"base64data"}
"key_endpoint_timeout" : 5, | HTTP timeout in seconds
"sign_authenticated" : 1, | Sign for authenticated senders
"sign_local" : 1, | Sign for local IP senders
"add_message_instance" : 1, | Add Message-Instance headers
"record_smtp_params" : 1, | Record MAIL FROM/RCPT TO in signature
"snapshot_directory" : null, | Snapshot dir (shared with DKIM2Verify)
"max_recipe_literals" : 1000, | Body Recipe literal-line cap
"ignore_header_prefixes" : [], | Own fields hashed by neither end
"allow_null_body_recipe" : 0, | Sign over an unsigned
| Message-Instance with a null
| body Recipe (list hosts)
"dns_overrides" : null, | Path to dns.json for testing
"skip_timestamp_check" : 0 | Testing: ignore upstream t=/expiry
}
The values shown are the defaults. authentication_milter does not fill in a handler's defaults itself, so the handler does: an option left out of the configuration (or set to null) takes the value above, and an explicit value, 0 included, is used as given.
allow_null_body_recipe is dkim2-milter's --allow-null-body-recipe: off by default, for a list host whose list manager rewrites bodies and adds an unsigned Message-Instance with a null body Recipe for this handler to sign. It never excuses a broken chain. dns_overrides and skip_timestamp_check only affect the verification of the upstream chain, and are for tests.
When snapshot_directory is set, the handler looks up the stored message snapshot DKIM2Verify took of this message on inbound, named by the X-DKIM2-Snapshot token DKIM2Verify added -- never by Message-Instance value, which a replayed instance over an edited body would share with another message. If found, and its Message-Instances are the message's lower ones, a diff-based MI is computed capturing header and body changes made during local processing. A message whose top Message-Instance does not match and that has no such snapshot gets no new MI (and the gate refuses it); one with no Message-Instance at all gets m=1. The handler removes X-DKIM2-Snapshot from every message it sees, signed or not, and X-DKIM2-Exploded too: a message that carried one (Mailman and Sympa mark each distribution copy X-DKIM2-Exploded: yes) is signed with the exploded flag (-07 section 12.5), as is one with more than one recipient, never at i=1. A snapshot diff whose body Recipe would need more than max_recipe_literals literal lines declares the body unrecoverable (a null body Recipe): this handler only adds header fields, so it cannot move the old body into the MIME epilogue. Like any null this hop adds, it is signed only with allow_null_body_recipe; without it the message goes out unsigned.
A message with no DKIM2-Signature whose Message-Instances (all unsigned: an m=1 stamped on arrival, a list manager's instance over it) are more than one matching m=1 is signed with one Message-Instance, m=1 (-07 section 12.1): the hashes of the copy sent and Recipes back to the message as it arrived, a From munge included. The instances it carried are removed, with X-DKIM2-Info: action=restarted-unsigned-mi=m=1,...;. The message 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 gate refuses the message. The From sent must still be Authentic for the signing domain (-07 section 11). A message that arrived signed is extended with m=N+1 as before.
HTTP KEY ENDPOINT
When key_endpoint is configured, the handler will make a GET request to:
{key_endpoint}?domain={domain}
The endpoint should return a JSON object with:
{
"selector": "sel1",
"keyfile": "/path/to/private.pem"
}
or:
{
"selector": "sel1",
"key": "-----BEGIN RSA PRIVATE KEY-----\n..."
}
Return HTTP 404 or an empty response to decline signing for that domain.
CALLBACKS
default_config()
Returns the default configuration hash for this handler. The handler also applies it at runtime to any option the configuration leaves out.
register_metrics()
Returns the metrics hash for this handler (dkim2_sign_total).
envfrom_callback($env_from)
Resets per-message state and records the envelope sender.
envrcpt_callback($env_to)
Records each envelope recipient for the m= SMTP params tag.
header_callback($header, $value, $original)
Collects each header line for message reconstruction.
eoh_callback()
Called at end of headers. Resets the body carry buffer.
body_callback($body_chunk)
Collects body chunks, normalizing line endings to CRLF.
eom_callback()
Called at end of message. Flushes any remaining body carry data. Actual signing is deferred to addheader_callback().
addheader_callback($handler)
Performs the signing. Determines the signing domain from the envelope sender, looks up the key configuration, computes a Message-Instance header if configured, applies Mail::DKIM2::Gate (see "DESCRIPTION"), creates the DKIM2-Signature, and adds both as prepended headers via the milter handler object. Broken intermediate Message-Instances it stripped are deleted with change_header().
close_callback()
Cleans up per-message state.
AUTHOR
Bron Gondwana <brong@fastmailteam.com>
COPYRIGHT AND LICENSE
Copyright (c) 2025-2026 Fastmail Pty Ltd. This is free software; you can redistribute it and/or modify it under the same terms as Perl itself.