Revision history for Mail::DKIM2 0.21 2026-10-14 - Mail::DKIM2::Wrap::wrap_single_part, in list context, also returns why it wrapped nothing: multipart (the normal case), no-header-end, unparsable-header or boundary. Scalar context is unchanged. - dkim2-milter --wrap-single-part logs that reason; it logged every case as "already multipart". 0.20 2026-10-14 DKIM2 for stock Mailman, with dkim2-milter and Postfix routing only. - New: dkim2-milter --wrap-single-part (inbound mode, needs --snapshot-dir). A single-part post is wrapped as a one-part multipart/mixed (its Content-* fields and body unchanged), the wrap recorded as its own unsigned Message-Instance, and the wrapped message kept as the snapshot the outbound milter diffs against. Stock Mailman then adds its footer as an extra part rather than re-encoding the body. See docs/dkim2-postfix-stock-mailman-guide.md in the interop repository. - New: Mail::DKIM2::Wrap (wrap_single_part). - Behaviour change: dkim2-milter in outbound mode adds no Message-Instance to mail it does not sign (no key for the sender, a refused chain, a signer failure), and removes every Message-Instance no DKIM2-Signature covers -- instances hops on this host added for a signature that is not going to be made -- recording it as X-DKIM2-Info: action=removed-unsigned-mi=...;. It used to add an unsigned Message-Instance and leave the others, which receivers report as permerror ("m=N is not signed"). An unsigned post now leaves with no DKIM2 at all, and a signed one fails honestly against the sender's signature. DKIM2Sign does the same on a refusal, a domain with no signing configuration, or a Signer failure. - Fix: dkim2-milter in outbound mode computes its own Message-Instance (the diff against the snapshot) before checking the upstream chain, and checks the message as it will be signed. It used to check first, so a list manager that changed a message without recording an instance of its own -- every list copy whose changes only the snapshot diff records -- was refused. - Security fix: dkim2-milter no longer finds snapshots by Message-Instance value. A post that replayed a sender's genuine m=1 over an edited body found the genuine post's snapshot, and the edit was recorded and signed as this host's own change. The inbound milter now snapshots only a message it stamps m=1 or whose top instance matches the content, stores the snapshot under a random token and names it in a new X-DKIM2-Snapshot header; the outbound milter diffs only against the snapshot that token names. Every mode strips X-DKIM2-Snapshot from the mail it sees, so a token neither comes from outside nor leaves the host. The inbound and outbound milters must share --snapshot-dir (as before) and be upgraded together. The authentication_milter handlers do the same: DKIM2Verify names its snapshot in X-DKIM2-Snapshot (and removes any that arrives), DKIM2Sign finds it only through that token, and both must be upgraded together. - Behaviour change: since the outbound milter's own diff instance is now gated as an unsigned instance, a diff that falls back to a null body Recipe is signed only with --allow-null-body-recipe. - Fix: dkim2-milter registers SMFIF_CHGHDRS, so removing a broken intermediate Message-Instance (which calls chgheader) no longer dies in Sendmail::PMilter. The removals now go out before the new Message-Instance is inserted, so an MTA that counts inserted fields in a header index still removes the right one. 0.19 2026-10-09 Fixes from a follow-up review of 0.18. Malformed fields that used to verify are now errors; well-formed mail verifies as before, and signatures fold where they did, except as below. - Behaviour change: a DKIM2-Signature or Message-Instance must parse as a whole tag list (spec-06 sections 7, 8, 11.2). A fragment that is not name=value ("junk", "9bad=foo"), or a value with a byte outside printable ASCII or a ";", is PERMERROR "syntax error"; it used to be dropped and the rest verified. Well-formed unknown tags are still ignored and still signed. Mail::DKIM2::TagValueList gains split_tag_list() and syntax_error(). - Behaviour change: whitespace inside an s= selector or algorithm, or an h= hash name, is a syntax error. "rsa- sha256" used to be read as rsa-sha256. FWS around the item's colons and commas and inside base64 is still accepted. An h= hash-set missing a digest is a syntax error rather than skipped; parse_hash_sets() returns undef for a malformed value. - Behaviour change: every value in a key record, unknown tags' included, must fit the tag-list grammar; a NUL, DEL or 8-bit byte makes the record a syntax error. - Fix: the signer split a long d= domain (two 40-character labels) across lines, and the verifier then failed the MAIL FROM/d= match. fold_header() now folds a DKIM2 field only where its grammar allows FWS -- base64 interiors, beside s= and h= item colons, list commas other than in s= -- leaving an unbreakable token whole on a longer line, and croaks rather than emit a line over 998 characters. - Fix: header recipe generation was quadratic in repeated identical fields (16,000 took ~3s); it is linear, with identical recipes. - Fix: load() rethrows a host's exception object from the input's can() instead of replacing it with a "cannot read" croak. - Docs: Signature.pm comments describe the empty s= value and spec-06 9.6's WSP deletion. 0.18 2026-10-09 Fixes from an external review against spec-06. Several are behaviour changes for messages that are malformed or adversarial; well-formed mail verifies as before. - Behaviour change: only the signature algorithms rsa-sha256 and ed25519-sha256, matched exactly, are verified. An s= item naming anything else is ignored before its key is fetched (spec-06 §3.4); it used to be fetched and verified as RSA, so a correctly RSA-signed item declaring an unknown algorithm passed, and many distinct unknown names meant as many sequential DNS lookups. s= is parsed once, not once per item accessed. A signature whose items all name unknown algorithms is FAIL "has no signature with a supported algorithm"; a known item whose value is not a padded base64string is PERMERROR "syntax error"; a key of the wrong type for the algorithm is PERMERROR "algorithm mismatch". - Behaviour change: key records are validated whole (Mail::DKIM2::Common::parse_dkim_key_record, new): a repeated tag, v= not first or not exactly DKIM1, a missing, unpadded or non-base64 p=, or a p= that is not a key make the record a syntax error; an empty p= is revoked; k= other than rsa or ed25519 is no longer read as RSA. More than one TXT record for a selector is an error (one record in several strings is joined). The verifier reports these as spec-06 §11.5's PERMERRORs naming the selector, and a key absent for every item as PERMERROR "public key does not exist". parse_dkim_pubkey keeps its key-or-undef contract. - Behaviour change: Message-Instance tag names are case insignificant (spec-06 §7): M= and H= are read as m= and h= everywhere, and a tag repeated in any case is PERMERROR "syntax error" -- a wrong h= ahead of the right one used to be overwritten and pass. - Behaviour change: t= must be 1*DIGIT; anything else is PERMERROR "syntax error", even with SkipTimestampCheck, and t=0 is subject to the age check. - Behaviour change: a failed signature item reads "DKIM2-Signature i=N incorrect signature" (spec-06 §11.6). - Body Recipes come from a built-in capped Myers diff, the same algorithm as this repository's C, Python, Go and Mailman generators (vectors/body-diff.json); Algorithm::Diff is no longer a prerequisite. Repeated-line bodies no longer take quadratic time. A Recipe carries at most MaxRecipeLiterals literal lines (new calculate option, default 1000); over that the default path gives the null body Recipe, and EpilogueThreshold (no longer capped) the epilogue. dkim2-milter --max-recipe-literals and the DKIM2Sign handler's max_recipe_literals set it. An unchanged body under EpilogueThreshold gets no Recipe instead of an epilogue copy. - Behaviour change: every public constructor and class method that takes named options croaks on one it does not know, as the CONVENTIONS always said (Signature->new, MessageInstance calculate/verify/undo/chain_verifies, Gate->check, DSN generate/authenticate/propagate, MessageStore->new, Validate::report, fold_header). - Mail::DKIM2::DSN: authenticate and propagate take keys from DNS through Resolver when no PubkeyCallback is given, as documented; propagate croaks unless ForwarderDomain is the d= of the hop it strips; the POD says what ok means for an unsigned DSN. - Every eval in the library, Validate, Reflector and Split included, rethrows a host's exception object. - TagValueList/Signature parse() always returns a new object. - The README and Mail::DKIM2 SYNOPSIS sign and verify correctly, and t/synopsis.t runs them. 0.17 2026-10-08 - Mail::DKIM2::Gate: when the Gate runs the Verifier itself and it passes, the Verifier has already walked the whole Message-Instance chain, so the Gate no longer walks it a second time. It still does when there are no signatures, when the caller supplied VerifyResult, or when the Verifier did not pass. - The Gate's null-body refusal now names the library option (AllowNullBodyRecipe), not the CLI flag. dkim2sign and dkim2-milter still show --allow-null-body-recipe, and the authentication_milter DKIM2Sign handler shows allow_null_body_recipe. - authentication_milter DKIM2Verify: for a message that already has Message-Instance headers, skip the MessageInstance verify that only picked the snapshot key when snapshot_directory is not set. 0.16 2026-10-08 - Mail::DKIM2::Gate's null body rule: a DKIM2-Signature with m=k covers Message-Instances 1..k, so without AllowNullBodyRecipe the gate refuses when any instance with a null body Recipe has m= above the highest m= of the valid upstream signatures -- the top, or one with another unsigned instance added over it: a null this hop is the first to sign. A null an upstream domain already declared and signed (a list post forwarded unchanged) is extended without the option; the upstream chain must still verify and the header history below the null must still check out. In 0.15 any null top was refused, signed or not, and a null under an unsigned top was never looked at (so the DKIM2Sign handler, recomputing its own instance over a snapshot keyed by the null one, signed it without the option). The refusal reads "unsigned top Message-Instance m=N has a null body Recipe", or "unsigned Message-Instance m=N ..." when it is not the top. The result gains top_signed, covered_m and unsigned_null. bin/dkim2sign, bin/dkim2-milter and the DKIM2Sign handler follow the Gate and add the informational X-DKIM2-Info action=null-body-recipe when they sign over either kind of null. (t/gate-null-top.t, t/milter-sign-gate.t, t/sign-cli.t, t/milter-script.t; signer-gate fixtures null-top-signed, null-below-unsigned-top, null-below-signed) - Behaviour change: Mail::DKIM2::Verifier reports a DKIM2-Signature it cannot key -- no i=, an i= that is not a positive integer (i=, i=0, i=abc, i=-1), or one that does not parse -- as permerror, "PERMERROR DKIM2-Signature has a missing or malformed i= tag", as the Python, Go, C and JS verifiers now all do. It used to ignore such a field and verify the rest, so a junk "DKIM2-Signature: m=2" prepended to a valid chain passed -- and the Gate, counting coverage by m=, took it as signing an unsigned null top. Mail::DKIM2::Signer likewise refuses to sign over such a field (result fail with the same message), and only a signature with a valid i= counts towards the Gate's coverage. (t/verifier-unkeyable.t, t/gate-null-top.t) - Behaviour change: every DKIM2-Signature i= and m=, and every Message-Instance m=, must be a chain number: 1*DIGIT in ASCII (else "PERMERROR has a malformed = tag", or the unkeyable message above for i=), at most three digits naming 1..MAX_CHAIN_NUMBER (100) (else "PERMERROR = exceeds the maximum chain number of 100"), and no more than MAX_CHAIN_LENGTH (32) (else "... exceeds the maximum chain length of 32"). "01" and "001" are 1, in all five verifiers and four signers. Found while the header fields are read, before any gap check: i=99999999999999999999 used to kill the Verifier ("Range iterator outside integer range"), and m=4294967297x was read as its digit prefix. The Signer refuses to sign over such a field. Common exports chain_number_error(), MAX_CHAIN_NUMBER and mi_version_tag() (the raw m= value, from any position in the field); extract_mi_version() is now numeric and returns undef unless the whole m= is ASCII digits. (t/chain-number-bound.t) - The Verifier keyed Message-Instances by the m= string, so an instance written m=01 read as "missing Message-Instance m=1" in Perl alone; instances and the donotmodify check are keyed by number now. (t/chain-number-bound.t) - A duplicate key anywhere in a Recipe's JSON (at the top level or inside "h") is invalid JSON: "PERMERROR Message-Instance m=N contains invalid JSON". Parsers disagree on which value wins (C's kept the first, the others the last), so {"b":[...],"b":null} was a real body Recipe to some verifiers and gates and a null one to others. Common::decode_tag_json refuses it, so the Verifier, the Gate and the signers all do. (t/invalid-json.t; negative vectors recipe-duplicate-*, signer-gate fixture recipe-duplicate-key) - bin/dkim2-milter fails closed. Outbound, an exception in the Gate, in computing the Message-Instance or in the Signer is logged and the message goes on unsigned. Inbound, a Verifier exception gives dkim2=temperror in Authentication-Results (never pass), and an exception computing the Message-Instance is logged and adds no Message-Instance; the message goes on with its Authentication-Results. An out-of-range Message-Instance m= is never used as a bound when stripping instances above a snapshot (m=99999999999999999999 died there, m=4294967297 ran out of memory). A Signer that declines is logged too. (t/milter-script.t) - The DKIM2Sign handler (authentication_milter) follows the Gate, on the message as it would sign it (its own Message-Instance included), replacing its own chain_verifies() check. New config allow_null_body_recipe (default 0) is the milter's --allow-null-body-recipe. A refusal signs nothing and adds or strips no Message-Instance; it is logged, counted in dkim2_sign_total by reason, and (broken-mi-chain, null-body-recipe) marked with X-DKIM2-Info action=not-signed=. Upstream keys come from the milter's resolver (dns_overrides and skip_timestamp_check for tests). Mail is signed exactly as before when there is no upstream chain. Gate->check takes Resolver and IgnorePrefixes. The test mock of Mail::Milter::Authentication moved to t/lib/MockAuthMilter.pm. (t/milter-sign-gate.t) - Behaviour change: the DKIM2Sign and DKIM2Verify handlers apply their own default_config() to any option the configuration leaves out (or sets to null); an explicit 0 still wins. authentication_milter only uses default_config() to generate a sample config, so before this DKIM2Sign's sign_local, sign_authenticated, add_message_instance and record_smtp_params, and DKIM2Verify's add_message_instance, were off unless set. A deployment that relied on leaving them out to keep them off must now set them to 0. (t/milter-sign-gate.t, t/milter.t) - The DKIM2Sign handler deletes the broken intermediate Message-Instances it strips through the framework's change_header() (SMFIR_CHGHEADER). It used to push them onto the handler's remove_headers, which authentication_milter never reads, so they stayed on the wire. (t/milter.t) - Mail::DKIM2::Common exports valid_sequence() and UNKEYABLE_SIGNATURE_ERROR. - Signer POD: result() lists every fail cause. - bin/validate.pl reports the header-level PERMERRORs (a DKIM2-Signature with no usable i=, an i=/m= that is not a chain number) before its walk. It used to pass a message whose only DKIM2-Signature was junk. It also reads i=/m= with the tag-list parser, so FWS around "=" no longer breaks its walk. (t/validate-cli.t) - DKIM2_DATE 2026-10-08. 0.15 2026-10-08 - MessageInstance->calculate takes BodyRecipe ('none', 'null' or a Recipe) and then skips the body diff; body_hash accessor; body_digest_raw(). For list managers that build the Recipe from their own layout (Sympa always-wrap). (t/mi-body-recipe-option.t) - MessageInstance->calculate takes BodyHash (a base64 sha256 hash, or a hashref per algorithm, from body_digest_raw) with BodyRecipe or for m=1: the body is not hashed or read, so the message passed may be its header block alone. A list manager sending many copies of a large body no longer has each one parsed and hashed twice. body_digest_raw hashes the body a megabyte at a time instead of copying it whole. (t/mi-body-recipe-option.t) - calculate validates both: a BodyHash value must be the base64 of a digest of its algorithm's length (it goes into h= verbatim); a string message without a final line break gets a CRLF; a BodyRecipe of undef, with an undefined step or a literal holding CR or LF, or with no previous message croaks; BodyRecipe 'none' with BodyHash croaks unless the hash is the previous instance's body hash. (t/mi-body-recipe-option.t) - The body diff finds the common prefix and suffix a 4 KB block at a time instead of a character at a time: about 5x faster for a list manager's per-recipient Recipe (2 MB body: 104 ms to 21 ms), the same Recipes. (t/mi-flat-common-len.t) 0.14 2026-10-07 - The Verifier and MessageInstance->chain_verifies walk the header history past an instance whose body Recipe is null: the body is unrecoverable there, but every lower instance's header hashes are still checked down to m=1. (t/mi-null-header-history.t) - Validate (and bin/validate.pl): a null body Recipe no longer fails the chain; the header history below it is checked, and the lower levels report body_hash 'not-checked'. (t/validate-null-body.t) - dkim2-milter --allow-null-body-recipe (default off): a message whose top Message-Instance has a null body Recipe is no longer signed silently; it is refused (X-DKIM2-Info not-signed=null-body-recipe) unless the option is set, and signed with X-DKIM2-Info null-body-recipe when it is. - bin/dkim2sign no longer signs blindly over an existing chain: like dkim2-milter it verifies the upstream DKIM2-Signatures (an unsigned top Message-Instance is allowed) and that the Message-Instance chain undoes cleanly, and refuses (exit 1, nothing on stdout) otherwise. A top null body Recipe is refused unless --allow-null-body-recipe. New --dns-json (default $DKIM2_DNS_JSON) and --ignore-timestamps. The decision is the new Mail::DKIM2::Gate, shared with the milter; the Signer library itself still signs ungated. (t/sign-cli.t) - nd= bridge: when the top DKIM2-Signature carries nd=, the Gate (and so dkim2sign and dkim2-milter) signs only if nd= equals the signing d= (case-insensitive) and otherwise refuses with "top signature nd=X names another domain". Verifier gains next_domain_ok($d) / NextDomainOK; Gate->check takes SigningDomain. Every non-nd case behaves as before. (t/sign-cli.t, t/milter-script.t) - A Message-Instance whose r= has a "b" that is neither null nor an array (e.g. 5, "x", {}) is now a PERMERROR instead of being read as a null body Recipe; likewise an "h" that is not an object. (t/mi-null-recipe.t) - A body Recipe's structure (integer bounds, ascending, no overlap) is validated even when a null body Recipe above it means it is never applied; a malformed one below a null now fails the chain. (t/mi-null-header-history.t) - DKIM2_DATE is 2026-10-07: what the milter signs and how the Verifier judges a null body Recipe changed. 0.13 2026-10-04 - The header Recipe builder treated "no instances" and "one empty instance" of a field as the same, so removing an empty header (a bare "Bcc:", which Sympa's egress now removes) went unrecorded and the Message-Instance did not verify. (t/recipe-empty-header.t) 0.12 2026-10-04 - undo() rebuilt a base64 or quoted-printable body encoded twice: Recipes work on wire lines, so the rebuilt body is already in its transfer encoding, and Email::MIME->body_set encoded it again. Every such message a list re-encoded (footer appended, body re-wrapped) failed "m=1 does not match content" and the milter refused to sign it -- 17 of 88 charset-corpus samples through Mailman, while the Python undo rebuilt them byte for byte. The body is now set as raw octets. (t/undo-encoded-body.t) - DKIM2_DATE is 2026-10-04: the Message-Instance headers this library emits changed shape in 0.11 ("b" literals, integer copy ranges), and the X-DKIM2-Info date stamp follows emitted-header changes. 0.11 2026-10-04 Fixes found by replaying public-archive mail in assorted charsets (ISO-2022-JP, GB2312/GB18030, Big5, EUC-KR, Latin-1, raw 8-bit headers) through the signers, verifiers and list managers (interop util/charset-corpus.sh). - Recipe literals carrying any octet >= 0x80 are emitted as a new {"b": [base64, ...]} step instead of {"d": [...]}. A literal is the raw octets of a header value or body line; JSON text is UTF-8, so the old encoder wrote ISO-2022-JP, GB18030, Big5 and Latin-1 octets into the JSON as they were, which no strict JSON parser reads back. The decoder rejects a "b" item that is not RFC 4648 base64 or decodes to something containing CR or LF. Agreed extension to spec-06 §5, proposed to the WG. (t/recipe-base64.t) - Recipe copy ranges must ascend (spec-06 §5.1): each "c" step starts after the one before it ends. undo() used to sort the ranges and reject only overlap; it now rejects an out-of-order range too, for body and header Recipes alike. The header Recipe builder in calculate() no longer emits one: a header instance a hop moved above one it left alone is recorded literally. (t/recipe-order.t, t/undo-bounds.t) - Two more Recipe schema rules the other verifiers already hold, so every implementation gives the same verdict: a "c" bound must be a JSON integer (a string such as "2" is malformed; told apart by the scalar's flags, not its text), an empty "d" or "b" array is malformed (minItems 1), and so is a "d" string containing CR or LF (§5.1/§5.2 MUST NOT). (t/recipe-order.t, t/recipe-base64.t) - Recipe copy ranges are emitted as JSON integers. An index used as a hash key while de-duplicating header copies was stringified in place, so every Sympa Message-Instance carried {"c":["2","2"]}, which the spec-06 schema forbids and strict verifiers reject. (t/recipe-integers.t) - A broken Content-Type (`text/plain; Windows-1252`) no longer makes every verification print Email::MIME's "Illegal parameter" warning: the library parses with parameter checking relaxed, for the parse only, since DKIM2 never reads a MIME parameter. (t/malformed-content-type.t) - The test suite is self-contained: the test keys and dns.json ship under t/data/ (t/data-in-sync.t keeps them equal to the interop repository's shared copies), bin/validate.pl takes --dns-json and defaults to that copy, and the tests that cross-check the other implementations or the deployment templates skip outside the repository. 0.10's tests could not run from the tarball. 0.10 2026-10-02 API cleanup ahead of a CPAN release. Incompatible changes are marked *. - New top-level Mail::DKIM2 module documenting the conventions every module follows; every module now carries the distribution $VERSION. - Constructor options are CamelCase and validated: an unknown option croaks. Verifier options (SkipTimestampCheck, AllowUnsignedMI, MidProcess, HeadersOnly, PubkeyCallback, Resolver, IgnorePrefixes) can be given to new() and are no longer silently discarded. - load($input): one-shot PRINT+CLOSE taking a string, scalar ref, filehandle or Email::MIME, normalising LF to CRLF. TIEHANDLE lets a Signer or Verifier be tied to a filehandle. - Verifier->signatures and ->top_signature for Authentication-Results writers. * Ignore prefixes are per instance: Common::ignore_header_prefixes is gone; pass IgnorePrefixes to Verifier->new and to MessageInstance->calculate/verify/chain_verifies instead. should_skip takes the prefixes as a second argument. * Key fetching moves to the Verifier: Signature->fetch_public_key is replaced by Verifier->fetch_public_key($signature, $idx), driven by the Resolver option. Only NXDOMAIN/NOERROR/NODATA are permanent; any other resolver error is temperror. The pubkey callback receives the verifier as a third argument. * Signer no longer dies from inside PRINT on a chain it cannot extend: result() is 'fail' and details() says why. result() is undef (not '?') before CLOSE. details() and result_detail() added. * Signature: mail_from(), rcpt_to() and flags() are get/set like the other tag accessors; set_rcpt_to is removed. * Mail::DKIM2::DSN methods take CamelCase named arguments (Message, Signer, To, ReportingMTA, Status, Reason, PubkeyCallback, ForwarderDomain, SkipAuthentication, SkipTimestampCheck) instead of a hashref. Validate::report takes PubkeyCallback, DnsPath, SkipTimestampCheck. * Command-line tools: dkim2sign (was dkim2sign.pl) and the new dkim2verify are installed; verify-sig.pl, calculate-dkim2.pl and the *-mailversion tools are removed. - POD rewritten for spec-06 (the previous text described draft-clayton-08 tags); Net::DNS declared as a prerequisite. - dkim2-milter and dkim2-split-lmtp are installed programs (were bin/*.pl, run from the checkout). X-DKIM2-Info sw= says dkim2-milter. 0.01 2026-03-08 - Initial release - Implements draft-clayton-dkim2-spec-08 - Signer: streaming DKIM2-Signature generation with SMTP param recording - Verifier: full chain verification (all signatures, not just outermost) - MessageInstance: calculate, verify, and undo with header/body diff recipes - Signature: tag-value parser with base64-encoded JSON tags - HeaderParser: thin streaming base class replacing Mail::DKIM::Common - Common: shared canonicalization, hashing, domain matching utilities