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.

This module implements draft-ietf-dkim-dkim2-spec-06; 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-06, 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)
}

When snapshot_directory is set, the handler looks up a stored message snapshot (written by DKIM2Verify on inbound) using the topmost Message-Instance header value as the key. If found, a diff-based MI is computed capturing header and body changes made during local processing. Without a snapshot, a simple hash-only MI is computed.

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.

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, creates the DKIM2-Signature, and adds both as prepended headers via the milter handler object.

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.