Revision history for Mail::DKIM2
0.27 2026-10-21
Fix a test failure on hosts without JSON::XS.
- A Recipe number with an exponent ({"c":[1e0,3]}) is refused
whichever JSON backend JSON.pm picks. JSON::PP decodes 1e0 as
an integer where JSON::XS gives a float, so with JSON::PP 0.25
and 0.26 accepted it and failed t/recipe-order.t on install.
decode_tag_json now refuses any number with a fraction or an
exponent from the JSON text itself.
- New t/json-backend.t runs the check under JSON::PP.
0.26 2026-10-20
The Message-Instances are verified before the signatures, and a
message in a file can be signed and gated without reading it into
memory. No change to anything on the wire; which error is primary
changes for messages with more than one fault.
- New Mail::DKIM2::BodyHasher: the body hash body_digest_raw
gives, from a message fed through PRINT/CLOSE or load() in
pieces, holding at most a megabyte of the body. Its
body_hashes are what calculate's BodyHash and verify's
BodyHashes take, so a Message-Instance can be computed or
checked from the header block alone.
- Verifier order: the Message-Instances are checked first (each
one's content, then the walk, section 14.7), the signatures
second (sections 14.2 to 14.6), From last. No key is needed to
refuse a message whose instances do not describe it, and with
several faults a hash mismatch is now the primary error over a
timestamp, custody, key or signature error.
- Under AllowUnsignedMI or MidProcess, Message-Instances with no
DKIM2-Signature are walked: 'none' if they match the message,
the walk's fail or permerror if not (it was 'none' unread).
- New Verifier option InstancesOnly (instances_only): the chain's
shape and the Message-Instances, then stop, with no key fetched;
'pass' ("Message-Instance m=1..N intact") when they match.
- Gate->check takes a filehandle (or any object with getline) as
well as a string, and streams its body into the Verifier. The
chain is walked once, always by the Verifier (InstancesOnly with
no signature or with VerifyResult); it no longer calls
MessageInstance->chain_verifies.
- The Signer discards the body as it arrives (section 12.8 signs
none of it), and may be fed the header block alone.
- load() reads a filehandle a piece at a time, and stops reading
once the parser has stopped (a Verifier with no signature to
check, after the header fields), instead of reading it whole.
0.25 2026-10-20
Which error is reported when a message has more than one fault
(the interop contract's C9, so every implementation names the
same one), and one verdict.
- Section 14.2: the chain's structure before any field's content,
in a fixed order: too many fields; a Message-Instance with no
usable m=; an m= given twice; a DKIM2-Signature with no usable
i= (or one out of range); an i= given twice; a missing
Message-Instance; an instance no signature covers; a missing
DKIM2-Signature. Then each Message-Instance's content by
ascending m= (it was highest first), then each signature's.
- A number given twice is reported by its lowest value, not the
first met.
- Section 14.7: within one instance every header hash, in the
order h= lists the algorithms, before any body hash (it was each
algorithm in turn, alphabetically).
- A Message-Instance with no m= is "PERMERROR: Message-Instance
m= tag=m missing" (was "... without m= tag"); new constant
Mail::DKIM2::Common::MI_MISSING_M_ERROR.
- The structure is judged at the end of the header fields, so a
message whose chain is broken there stops before any key is
fetched.
- Verdict change: a message with no DKIM2-Signature and a gap in
its Message-Instances (only m=2, say) is "PERMERROR: Message-
Instance m=1 missing" with AllowUnsignedMI or MidProcess too; it
used to be "none" there. "none" is only for a message with no
DKIM2 field at all. (The reflector, which uses AllowUnsignedMI,
no longer bridge-signs such a message.)
0.24 2026-10-19
Fixes to the public-key prefetch added in 0.22, which made every
DKIM2-signed message cost about five seconds behind a resolver that
customises send or query, and one API change.
- No prefetch through a resolver that subclasses Net::DNS::Resolver
and overrides send or query (as FastMTA's and authentication_
milter's do) without its own bgsend: Net::DNS's bgsend bypassed
that override and asked only the first nameserver, and on a host
where those background answers never arrived every key waited out
the deadline before query() asked again. Such a resolver now gets
every lookup through query(). A plain Net::DNS::Resolver, or one
with its own bgsend, is prefetched as before.
- At most $Mail::DKIM2::Verifier::PREFETCH_MAX (8) keys are asked
for in the background per message, the top signature's first. The
prefetch runs before any field is judged, and a message naming
thousands of selectors opened a socket and sent a query for each;
a key not prefetched is asked for with query() if it is needed.
- $Mail::DKIM2::Verifier::PREFETCH_WAIT is 1 second (was 5): an
answer that has not arrived by then is asked again with query().
- Waiting for a background answer sleeps between polls (5ms,
$Mail::DKIM2::Verifier::PREFETCH_POLL). Net::DNS's own bgbusy
already waited briefly, but a resolver whose bgbusy does not block
made the old loop spin.
- A truncated background answer (Net::DNS's TCP retry failed) is
asked again with query() instead of being used.
- API change: Signer->as_string croaks "no signature: REASON" when
there is no signature (before CLOSE, or when result is "fail");
it used to return the empty string, which a caller that
prepended it without checking result turned into a blank line
ending the message's header block. Check result first, as every
caller in this distribution does.
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.
0.22 2026-10-18
- Message hashes (every Message-Instance's header and body hashes)
go through the new Mail::DKIM2::Digest: OpenSSL's SHA-2, through
Net::SSLeay, when that is installed (recommended, not required),
else CryptX's as before. The digests are the same; OpenSSL uses
the CPU's SHA instructions where there are some -- about 7 times
CryptX's speed on an Apple M-series, 1.8 times on an older x86.
- The body is hashed where it lies, in whole runs, with fewer
copies: load() normalises line endings only when there is a bare
LF and never changes the caller's string; PRINT hands body chunks
straight to the Verifier's body plan.
- The Verifier checks the Message-Instance chain without rebuilding
any previous version of the message. New: Mail::DKIM2::BodyPlan
composes the body Recipes into, for each instance, the runs of
current body lines and Recipe literals its body was, and hashes
every instance's body in one pass as the body arrives; the body is
not kept. New: Mail::DKIM2::HeaderPlan does the same for header
fields, canonicalizing each hashed value once. Same verdicts, in
the same words; about 40% less time on list mail, all of it in
the chain check. Verifier option StreamBody (on by default); 0 is
the undo walk.
- DKIM2Verify keeps a copy of the body only when it builds something
from it (a snapshot, or a first Message-Instance).
- The Verifier sends for every signature's public key, all at once and
in the background (Net::DNS bgsend), as soon as it has the header
fields, and reads the answers when it checks the signatures; an
answer that settles nothing (none within 5 seconds, SERVFAIL) is
asked again with query(). Each key name is looked up once per
message. A chain of five signing domains verified about four times
faster against live DNS.
- dkim2-milter verifies as the message arrives (headers at end of
headers, then each body chunk), so the key lookups overlap the
body's transfer.
- MessageInstance->verify takes BodyHashes, body hashes computed
elsewhere.
- Behaviour change: a body Recipe may copy the body's trailing blank
lines. They are not hashed (spec-06 section 6.1), but they are lines,
as C, Go, Python and the browser verifier already number them; undo
used to stop counting at the last non-blank line and refuse such a
copy.
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 <sel> 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 <selector> 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 <field> has a malformed <tag>= tag", or the
unkeyable message above for i=), at most three digits naming
1..MAX_CHAIN_NUMBER (100) (else "PERMERROR <field> <tag>= 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=<reason>. 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
Keyboard Shortcuts
Global
s
Focus search bar
?
Bring up this help dialog
GitHub
gp
Go to pull requests
gi
Go to GitHub issues (only if GitHub is preferred repository)