Changes for version 0.23 - 2026-10-18
- Draft -07 (draft-ietf-dkim-dkim2-spec-07). The wire format and many verdicts change: a message 0.22 passed may not pass, and the other way round. DKIM2_DRAFT is ietf-dkim-dkim2-spec-07 and DKIM2_DATE 2026-10-18. New prerequisite: URI (URI::_punycode).
- Recipes (section 5)
- A Recipe literal that is valid UTF-8 (ASCII included) is a "d" step; one that is not is a "u" step, canonical base64 of the octets. The "b" step of 0.13 to 0.22 is gone: on the wire it is now an unknown step key, and invalid. MessageInstance->calculate emits "u" (the Sympa and Mailman handlers get it from there).
- Every step has exactly one key, "c", "d" or "u"; bare arrays and strings as steps, an empty step, two keys, an unknown key, a "u" string that is not canonical base64 (padding, zero pad bits, no FWS), a decoded "u" or a "d" holding CR or LF, a "c" that is not two integers of at least 1, "h": {}, "h": null, and a Recipe object with neither "h" nor "b" are all "PERMERROR: Message-Instance m=N contains invalid JSON". So, now, is every Recipe that does not undo: a copy range past the end, out of order or overlapping.
- JSON keys are matched without regard to case at every level ("H", "Subject", "C" are read as "h", "subject", "c"); two keys in one object that differ only in case are a duplicate, invalid JSON. Keys are emitted in lower case.
- Common::decode_tag_json dies on JSON text that is not UTF-8 and on a duplicate key in any case, and returns every string, keys included, as UTF-8 octets (\u escapes too).
- A Message-Instance with no r= at m=2 or above is "no change", as before; now tested.
- Tag lists (section 8)
- An empty tag-spec before the end (";;", a leading ";") is a syntax error in a Message-Instance and a DKIM2-Signature; one trailing ";" is still allowed.
- A DKIM2-Signature tag repeated in any case is "syntax error" (was "duplicate tag X not permitted"). A Message-Instance with no h= is "tag=h missing".
- Flags (sections 10.10, 12.5)
- donotmodify and donotexplode are no longer enforced: f= is a list of flags with no verifier meaning.
- New Signer option Exploded. A signature carries f=exploded when i > 1 and Exploded (or "exploded" in Flags) is set, or rt= holds more than one address; never at i=1, where a caller-supplied "exploded" is dropped. sign_for_recipient works the flag out afresh for each recipient set (it builds a new Signature object).
- dkim2-milter and the DKIM2Sign handler treat an X-DKIM2-Exploded header field (any value; Mailman and Sympa add one to each distribution copy) as the request, and remove it before signing. dkim2sign has --exploded.
- Chain of Custody (sections 7, 14.4)
- Every signature is checked against the one below it: after an nd= hop, d= must be that nd= ("i=P nd= does not match"); an nd= hop or one with a null mf= must have its d= at or above a previous rt= domain ("i=S d= does not match the rt= of i=P"); any other its mf= domain at or below one ("i=S mf= does not match the rt= of i=P"). A null mf= used to fail outright, and an nd= hop's d= was compared the other way round. Every non-null mf= must be at or below its own d=, on every signature, not only where it was checked before. All are PERMERROR (were "fail").
- nd= with mf= or rt= is "tag=mf (or rt) was unexpected" (was "tag=nd was unexpected"); a missing rt= is "tag=rt missing".
- New Common::domain_of($addr): the domain of an address or path, lowercased, one trailing dot off ("<A@Example.COM.>" is example.com), and whether it is a domain literal.
- New Common::to_alabel($domain): non-ASCII labels as A-labels (xn-- Punycode). Signers now record mf= and rt= domains as A-labels.
- Common::relaxed_domain_match compares A-labels, ignores a trailing dot, and never matches a domain literal; only its first argument is shortened, as before.
- From must be Authentic (sections 11, 12.4, 14.8)
- The Verifier checks the From in effect when each signature was made: the From at state m(i=1) against i=1, and, for each i >= 2 where an instance in (m(i-1), m(i)] changed From, the From at state m(i) against i: exactly one From field, one mailbox, its domain (as A-labels, never a literal) at or below d=. Otherwise "PERMERROR: DKIM2-Signature i=N matching From is not authentic". A From replaced before any signature covered it (an unsigned ingress m=1 a list rewrote in m=2 before signing i=1) is never checked. Instances no signature covers (AllowUnsignedMI, the signer gate) are not checked: the Signer checks those.
- The Signer refuses (result fail, details "From header field is not Authentic for d=D") when the message has no DKIM2-Signature yet, or a Message-Instance no signature covers names From, and the From is not Authentic for its Domain.
- New Common::from_domain(@values) and Common::from_authentic($d, @values); new HeaderPlan::values_at($m, $name).
- DSN->generate and DSN->propagate sign From "Mail Delivery System <postmaster@" the signer's Domain ">".
- An unsigned post on the stock-Mailman route (and any message whose Message-Instances no DKIM2-Signature covers) leaves dkim2-milter and the DKIM2Sign handler with ONE m=1, as -07 section 12.1 allows: hashes of the copy sent and Recipes back to the post as it arrived, a From munge included (so C5 checks the munged From against i=1). The instances it carried are removed and X-DKIM2-Info records action=restarted-unsigned-mi=m=1,...; the message as it arrived comes from those instances when the top one matches, else from the X-DKIM2-Snapshot snapshot. A post that arrived signed is extended as before (m=N+1).
- MessageInstance->calculate($msg, $previous) with no instances on either gives m=1 with Recipes (it died before). New MessageInstance->calculate_first($msg, $previous): the m=1 above, undoing every instance of $previous.
- Error strings and results (section 14)
- details() is the -07 human-readable string, state included: "STATE: text", e.g. "PERMERROR: Message-Instance m=2 missing", "FAIL: Message-Instance m=1 body hash sha256 mismatch". Checks with no -07 string keep their words with a "PERMERROR: " prefix, including Common's chain_length_error, chain_number_error (a malformed number is now "Field tag=X syntax error"), duplicate_number_error and UNKEYABLE_SIGNATURE_ERROR, and Signature::check_duplicates. For pass and none, details() is unchanged.
- Now permerror, were fail: a missing DKIM2-Signature or Message-Instance (also one a signature covers that is not there), every Chain of Custody failure, a timestamp out of range, an unbracketed mf= or rt= ("syntax error"), no algorithm this verifier implements, a Recipe that does not undo.
- Every signature item that can be checked is checked: all failing is "FAIL: DKIM2-Signature i=N selector S incorrect signature", some passing and some failing "FAIL: DKIM2-Signature i=N signature S1 signature S2 failed" (one failing used to end the check).
- "TEMPERROR: DKIM2-Signature i=N public key S could not be fetched" loses its "(why)" suffix: the resolver's reason, and the range behind a Recipe that does not undo, are in the new Verifier::diagnostic().
- Every Message-Instance is parsed (syntax, h=, Recipe JSON) before any signature is worked out, so a malformed one is reported as such rather than as the signature it breaks.
- MessageInstance->verify and ->chain_verifies give the same strings; chain_verifies gives the reason a Recipe did not undo as a third value.
- Timestamps (section 14.3)
- A t= more than a day ahead is "PERMERROR: DKIM2-Signature i=N signature is from the future" (was five minutes, and fail); more than 14 days old "signature expired" (was fail). New Common constants FUTURE_LIMIT (86400) and EXPIRY (1209600).
- DSNs and SMTP (sections 13, 16)
- New Verifier::provisionally_verified (section 13.1): the top signature is correct and the top Message-Instance matches, whatever else failed.
- New DSN->may_generate: never a DSN when the top mf= is <> or the message is not Provisionally Verified. DSN->generate croaks unless it may (new options PubkeyCallback, Resolver, SkipTimestampCheck), and no longer falls back to the From header field for the destination. DSN->propagate croaks rather than send towards a null mf=.
- Reflector::generate_dsn and dkim2-reflector.pl send a DSN only for a Provisionally Verified message, and log why not.
- dkim2-milter and the DKIM2Sign handler reject with 550 5.7.1 and the reason (smfi_setreply, reject_mail) a message carrying an upstream DKIM2-Signature that the gate or the Signer refuses to sign; such a message used to be sent on unsigned. One with no signature still goes on unsigned, this host's instances removed.
- Reflector (section 11)
- When it originates the message (no DKIM2 chain to continue) or changes it (any mode but raw), From becomes "<sender's name or address> via DKIM2 reflector" <reflector-<mode>@<domain>>, the sender's From kept as Reply-To if there is none. Originating without a DKIM2-Signature to continue drops the inbound Message-Instance and starts again at m=1.
- Validate.pm reports Chain of Custody by the -07 rules and the same strings, and grades a timestamp over a day ahead as "future".
- Error strings for i= and m= numbers, as all five interop verifiers now give them: a DKIM2-Signature with no i= is "PERMERROR: DKIM2-Signature i= tag=i missing" (UNKEYABLE_SIGNATURE_ERROR, was "has a missing or malformed i= tag"), one whose i= is not a positive integer "i=<as written> syntax error" (new Common::unkeyable_signature_error); a signature's malformed m= is "DKIM2-Signature i=<i> syntax error"; an out-of-range number names the field and the value ("DKIM2-Signature i=33 exceeds the maximum chain length of 32", "DKIM2-Signature i=2 m=101 exceeds ...", "Message-Instance m=101 exceeds ..."). Common::chain_number_error takes the field's own number as a fourth argument.
- Check order and the primary result (interop contract C9)
- The Verifier runs its checks in -07 section 14 order, each over every signature from i=1 up: field syntax (14.2), timestamps (14.3), Chain of Custody (14.4), keys (14.5), signatures (14.6), hashes and the chain walk (14.7), From (14.8). The first error is the result, as in the other four interop verifiers. What changes: a Chain of Custody error now comes before a signature that does not verify (was the FAIL), a key error of any signature before another's bad signature, and an unexpected top nd= after the timestamps. A From changed after signing stays "FAIL: Message-Instance m=1 header hash sha256 mismatch".
- Every Message-Instance's Recipe copy ranges are checked for order and overlap with its other field errors, before any signature (new MessageInstance::recipe_order_error); one past the end of the message is still found by the chain walk.
- Common::from_domain: display text with no angle brackets ("Name a@b") is not one mailbox, so not Authentic.
- bin/validate.pl prints the primary error on a line of its own.
- dkim2-milter and the DKIM2Sign handler log, when the Signer refuses a From, "From domain X is not d=Y or below it" (new Common::from_refusal_note): a DKIM2 list's posting address must be in the domain the milter signs with.
- Disabled: MessageInstance->calculate and ->calculate_first croak on UseEpilogue (true) or EpilogueThreshold, and dkim2-milter refuses to start with --use-epilogue or --epilogue-threshold. The instance was computed over the message rewritten with the epilogue while the caller signed the original bytes, so that mail failed verification. The default path (a diff body Recipe, or a null one over MaxRecipeLiterals) is unaffected.
- Fixed: the DKIM2Verify handler's close_callback did not delete has_mi (added in 0.22), so authentication_milter logged "did not clean up data for key has_mi" and killed the child after every message. A new test checks both handlers leave no per-message key behind after close_callback.
Documentation
Standalone DKIM2 milter for Postfix
Modules
DKIM2 signing and verification for email
every Message-Instance's body hash in one pass
Canonicalization, hashing, folding and key handling for DKIM2
DKIM2-signed Delivery Status Notifications
SHA-2 over message content, by OpenSSL where it can
decide whether a front end may sign a message
Streaming message parser base for Signer and Verifier
every Message-Instance's header hash without undo
Compute, verify and undo Message-Instance headers
Keep message snapshots keyed by Message-Instance
verify-and-reflect DKIM2 demonstration logic for dkim2.com
One DKIM2-Signature header, parsed or under construction
Sign a message with a DKIM2-Signature header
Bcc-safe recipient grouping before DKIM2 signing
The tag=value list a DKIM2 header is made of
structured per-level DKIM2 and Message-Instance report
Verify the DKIM2-Signature chain on a message
wrap a single-part message in a one-part multipart/mixed
Handler class for DKIM2 signing
Handler class for DKIM2 signature verification